"""Generate the Make Birdhouses Backyard Habitat Utility Pack.

The pack contains three flat, support-free field and workshop tools:

* a 50.8 mm exterior window-marker spacing stencil,
* a nominal nest-box entrance-opening measurement gauge, and
* a calibrated 100 mm field-photo scale.

All three unsplit tools 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 ZIP.

Digital geometry checks do not replace slicing, printing, dimensional
inspection, field-use review, current wildlife guidance, or an effective
window treatment.  The stencil positions marks; it is not itself a treatment.
The entrance gauge measures openings; it does not select a species.
"""

from __future__ import annotations

from collections import Counter
import hashlib
import json
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, Polygon, Rectangle
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


OUTPUT_DIR = Path(__file__).resolve().parent / "habitat-utility-pack"

WINDOW_STENCIL_STL_PATH = (
    OUTPUT_DIR / "habitat-window-marker-grid-50p8mm.stl"
)
OPENING_GAUGE_STL_PATH = (
    OUTPUT_DIR / "habitat-nestbox-opening-gauge.stl"
)
PHOTO_SCALE_STL_PATH = (
    OUTPUT_DIR / "habitat-field-photo-scale-100mm.stl"
)
PLATE_STL_PATH = OUTPUT_DIR / "habitat-utility-pack-x1c-one-plate.stl"
PLATE_3MF_PATH = OUTPUT_DIR / "habitat-utility-pack-x1c-one-plate.3mf"
STEP_PATH = OUTPUT_DIR / "habitat-utility-pack.step"
TOOLS_PREVIEW_PATH = OUTPUT_DIR / "habitat-utility-pack-tools-preview.png"
PLATE_PREVIEW_PATH = (
    OUTPUT_DIR / "habitat-utility-pack-x1c-plate-preview.png"
)
USE_GUIDE_PATH = OUTPUT_DIR / "habitat-utility-pack-use-guide.png"
VALIDATION_PATH = OUTPUT_DIR / "validation-report.json"
README_PATH = OUTPUT_DIR / "README.md"
ZIP_PATH = OUTPUT_DIR / "habitat-utility-pack-v1.zip"
SLICED_3MF_SHA256 = (
    "FDABA1EE823F65140B6FAEC4A2D86A2BE9D4C797929AFFC707524E0ACA9291E5"
)

EXPORT_TOLERANCE_MM = 0.08
EXPORT_ANGULAR_TOLERANCE = 0.10

# Nominal X1C envelope and the same conservative front-left exclusion used by
# the other Make Birdhouses X1C packages.
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

TOOL_THICKNESS_MM = 2.4
ENGRAVING_DEPTH_MM = 0.45

# Window-marker layout tool.  The grid is exactly 2 inches in both axes.
WINDOW_GRID_MM = 50.8
WINDOW_GRID_COLUMNS = 4
WINDOW_GRID_ROWS = 3
WINDOW_APERTURE_DIAMETER_MM = 7.0
WINDOW_RING_OUTER_DIAMETER_MM = 13.0
WINDOW_RAIL_WIDTH_MM = 3.2
WINDOW_LABEL_WIDTH_MM = 56.0
WINDOW_LABEL_HEIGHT_MM = 14.0

# Nominal opening-comparison holes.  No species names are assigned.
OPENING_GAUGE_WIDTH_MM = 154.0
OPENING_GAUGE_HEIGHT_MM = 92.0
OPENING_GAUGE_CORNER_RADIUS_MM = 3.0
OPENING_DIAMETERS_MM = (25.0, 28.0, 32.0, 35.0, 38.0, 40.0)
OPENING_CENTERS_MM = (
    (-57.0, 22.0),
    (-15.0, 22.0),
    (35.0, 22.0),
    (-54.0, -19.0),
    (-1.0, -19.0),
    (53.0, -19.0),
)

# Photo scale.  The marked baseline is 100 mm from its first to last tick.
PHOTO_SCALE_LENGTH_MM = 126.0
PHOTO_SCALE_HEIGHT_MM = 36.0
PHOTO_SCALE_BASELINE_MM = 100.0
PHOTO_SCALE_TICK_MM = 10.0
PHOTO_SCALE_START_X_MM = -50.0
PHOTO_SCALE_END_X_MM = 50.0
PHOTO_SCALE_ARROW_BASE_X_MM = 50.0
PHOTO_SCALE_ARROW_TIP_X_MM = 63.0
PHOTO_SCALE_LANYARD_DIAMETER_MM = 5.0

# One-plate placements.  The photo scale is rotated vertically.
PLATE_OFFSETS = {
    "window-marker-stencil": (22.0, 10.0, 0.0),
    "opening-gauge": (22.0, 134.0, 0.0),
    "photo-scale": (190.0, 120.0, 0.0),
}

PACKAGE_FILENAMES = (
    WINDOW_STENCIL_STL_PATH.name,
    OPENING_GAUGE_STL_PATH.name,
    PHOTO_SCALE_STL_PATH.name,
    PLATE_STL_PATH.name,
    PLATE_3MF_PATH.name,
    STEP_PATH.name,
    TOOLS_PREVIEW_PATH.name,
    PLATE_PREVIEW_PATH.name,
    USE_GUIDE_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_box(
    width: float,
    height: float,
    depth: float,
    center_x: float,
    center_y: float,
) -> cq.Shape:
    """Return a Z-up box whose XY center and bottom face are specified."""
    return cq.Workplane("XY").box(
        width,
        height,
        depth,
        centered=(True, True, False),
    ).translate(cq.Vector(center_x, center_y, 0.0)).val()


def make_cylinder(
    diameter: float,
    height: float,
    center_x: float,
    center_y: float,
    z_min: float = 0.0,
) -> cq.Solid:
    return cq.Solid.makeCylinder(
        diameter / 2.0,
        height,
        cq.Vector(center_x, center_y, z_min),
        cq.Vector(0.0, 0.0, 1.0),
    )


def engrave(
    shape: cq.Shape,
    text: str,
    center_x: float,
    center_y: float,
    font_size: float,
    depth: float = ENGRAVING_DEPTH_MM,
) -> cq.Shape:
    """Cut bold Arial text into a flat tool's upper face."""
    text_shape = (
        cq.Workplane(
            "XY",
            origin=(center_x, center_y, TOOL_THICKNESS_MM + 0.02),
        )
        .text(
            text,
            font_size,
            -(depth + 0.02),
            combine=False,
            clean=True,
            font="Arial",
            kind="bold",
        )
        .val()
    )
    return shape.cut(text_shape)


def make_window_marker_stencil() -> cq.Shape:
    """Build a twelve-position, 50.8 mm two-axis marker stencil."""
    x_values = [
        column * WINDOW_GRID_MM
        for column in range(WINDOW_GRID_COLUMNS)
    ]
    y_values = [
        row * WINDOW_GRID_MM
        for row in range(WINDOW_GRID_ROWS)
    ]

    components: list[cq.Shape] = []
    for x_value in x_values:
        for y_value in y_values:
            components.append(
                make_cylinder(
                    WINDOW_RING_OUTER_DIAMETER_MM,
                    TOOL_THICKNESS_MM,
                    x_value,
                    y_value,
                )
            )

    for y_value in y_values:
        for column in range(WINDOW_GRID_COLUMNS - 1):
            components.append(
                make_box(
                    WINDOW_GRID_MM,
                    WINDOW_RAIL_WIDTH_MM,
                    TOOL_THICKNESS_MM,
                    (x_values[column] + x_values[column + 1]) / 2.0,
                    y_value,
                )
            )

    for x_value in x_values:
        for row in range(WINDOW_GRID_ROWS - 1):
            components.append(
                make_box(
                    WINDOW_RAIL_WIDTH_MM,
                    WINDOW_GRID_MM,
                    TOOL_THICKNESS_MM,
                    x_value,
                    (y_values[row] + y_values[row + 1]) / 2.0,
                )
            )

    grid_label = (
        cq.Workplane("XY")
        .box(
            WINDOW_LABEL_WIDTH_MM,
            WINDOW_LABEL_HEIGHT_MM,
            TOOL_THICKNESS_MM,
            centered=(True, True, False),
        )
        .faces(">Z")
        .workplane()
        .text(
            "50.8 MM",
            4.6,
            -ENGRAVING_DEPTH_MM,
            combine="cut",
            font="Arial",
            kind="bold",
        )
        .val()
        .translate(cq.Vector(76.2, 25.4, 0.0))
    )
    exterior_label = (
        cq.Workplane("XY")
        .box(
            WINDOW_LABEL_WIDTH_MM,
            WINDOW_LABEL_HEIGHT_MM,
            TOOL_THICKNESS_MM,
            centered=(True, True, False),
        )
        .faces(">Z")
        .workplane()
        .text(
            "EXTERIOR",
            4.4,
            -ENGRAVING_DEPTH_MM,
            combine="cut",
            font="Arial",
            kind="bold",
        )
        .val()
        .translate(cq.Vector(127.0, 76.2, 0.0))
    )
    components.extend((grid_label, exterior_label))

    stencil = components[0]
    for component in components[1:]:
        stencil = stencil.fuse(component)

    for x_value in x_values:
        for y_value in y_values:
            stencil = stencil.cut(
                make_cylinder(
                    WINDOW_APERTURE_DIAMETER_MM,
                    TOOL_THICKNESS_MM + 1.0,
                    x_value,
                    y_value,
                    -0.5,
                )
            )

    return stencil.clean()


def make_opening_gauge() -> cq.Shape:
    """Build a six-hole nominal nest-box opening measurement card."""
    gauge = (
        cq.Workplane("XY")
        .box(
            OPENING_GAUGE_WIDTH_MM,
            OPENING_GAUGE_HEIGHT_MM,
            TOOL_THICKNESS_MM,
            centered=(True, True, False),
        )
        .edges("|Z")
        .fillet(OPENING_GAUGE_CORNER_RADIUS_MM)
        .val()
    )

    for center, diameter in zip(
        OPENING_CENTERS_MM,
        OPENING_DIAMETERS_MM,
    ):
        gauge = gauge.cut(
            make_cylinder(
                diameter,
                TOOL_THICKNESS_MM + 1.0,
                center[0],
                center[1],
                -0.5,
            )
        )

    label_positions = (
        (-57.0, 2.5),
        (-15.0, 2.5),
        (35.0, 2.5),
        (-54.0, -42.3),
        (-1.0, -42.3),
        (53.0, -42.3),
    )
    for position, diameter in zip(
        label_positions,
        OPENING_DIAMETERS_MM,
    ):
        gauge = engrave(
            gauge,
            f"{int(diameter)}",
            position[0],
            position[1],
            4.6,
        )

    gauge = engrave(gauge, "NOMINAL MM", 0.0, 43.0, 4.0)
    return gauge.clean()


def make_photo_scale() -> cq.Shape:
    """Build a 100 mm photo scale with a visible orientation arrow."""
    outline = (
        cq.Workplane("XY")
        .moveTo(-63.0, -18.0)
        .lineTo(PHOTO_SCALE_ARROW_BASE_X_MM, -18.0)
        .lineTo(PHOTO_SCALE_ARROW_TIP_X_MM, 0.0)
        .lineTo(PHOTO_SCALE_ARROW_BASE_X_MM, 18.0)
        .lineTo(-63.0, 18.0)
        .close()
        .extrude(TOOL_THICKNESS_MM)
        .val()
    )

    scale = outline.cut(
        make_cylinder(
            PHOTO_SCALE_LANYARD_DIAMETER_MM,
            TOOL_THICKNESS_MM + 1.0,
            -57.0,
            0.0,
            -0.5,
        )
    )

    tick_count = int(PHOTO_SCALE_BASELINE_MM / PHOTO_SCALE_TICK_MM)
    for tick_index in range(tick_count + 1):
        x_value = (
            PHOTO_SCALE_START_X_MM
            + tick_index * PHOTO_SCALE_TICK_MM
        )
        major = tick_index in (0, tick_count // 2, tick_count)
        tick_width = 1.2 if major else 0.8
        tick_depth = 5.0 if major else 3.0
        for y_value in (
            18.0 - tick_depth / 2.0,
            -18.0 + tick_depth / 2.0,
        ):
            scale = scale.cut(
                make_box(
                    tick_width,
                    tick_depth + 1.0,
                    TOOL_THICKNESS_MM + 1.0,
                    x_value,
                    y_value,
                ).translate(cq.Vector(0.0, 0.0, -0.5))
            )

    scale = engrave(scale, "100 MM PHOTO SCALE", 0.0, 7.0, 4.2)
    scale = engrave(scale, "0", -50.0, -9.5, 4.0)
    scale = engrave(scale, "50", 0.0, -9.5, 4.0)
    scale = engrave(scale, "100", 43.0, -9.5, 3.8)
    return scale.clean()


def place_parts(
    window_stencil: cq.Shape,
    opening_gauge: cq.Shape,
    photo_scale: cq.Shape,
) -> dict[str, cq.Shape]:
    """Place all three unsplit tools on one guarded X1C plate."""
    photo_scale_vertical = to_origin(photo_scale).rotate(
        cq.Vector(0.0, 0.0, 0.0),
        cq.Vector(0.0, 0.0, 1.0),
        90.0,
    )
    source = {
        "window-marker-stencil": window_stencil,
        "opening-gauge": opening_gauge,
        "photo-scale": photo_scale_vertical,
    }
    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 export_models(
    window_stencil: cq.Shape,
    opening_gauge: cq.Shape,
    photo_scale: cq.Shape,
    placed: dict[str, cq.Shape],
) -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    exports = (
        (WINDOW_STENCIL_STL_PATH, window_stencil),
        (OPENING_GAUGE_STL_PATH, opening_gauge),
        (PHOTO_SCALE_STL_PATH, photo_scale),
    )
    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,
    )
    export_watertight_3mf(placed)
    exporters.export(plate, str(STEP_PATH))


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 canonicalize_mesh(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
    """Return a manifold mesh with reproducible vertex and face ordering."""
    quantized = np.round(np.asarray(mesh.vertices, dtype=float), 6)
    quantized[np.isclose(quantized, 0.0, atol=0.5e-6)] = 0.0
    vertices, inverse = np.unique(
        quantized,
        axis=0,
        return_inverse=True,
    )
    faces = inverse[np.asarray(mesh.faces, dtype=int)]
    if np.any(
        (faces[:, 0] == faces[:, 1])
        | (faces[:, 1] == faces[:, 2])
        | (faces[:, 2] == faces[:, 0])
    ):
        raise RuntimeError("Mesh quantization created a degenerate triangle")

    minimum_positions = np.argmin(faces, axis=1)
    faces = np.asarray(
        [
            np.roll(face, -int(minimum_position))
            for face, minimum_position in zip(
                faces,
                minimum_positions,
            )
        ],
        dtype=int,
    )
    order = np.lexsort((faces[:, 2], faces[:, 1], faces[:, 0]))
    canonical = trimesh.Trimesh(
        vertices=vertices,
        faces=faces[order],
        process=False,
    )
    if not canonical.is_watertight or not canonical.is_winding_consistent:
        raise RuntimeError("Canonical mesh is not manifold")
    return canonical


def deterministic_zip_info(filename: str) -> zipfile.ZipInfo:
    """Return a reproducible, deflated ZIP member descriptor."""
    info = zipfile.ZipInfo(
        filename=filename,
        date_time=(1980, 1, 1, 0, 0, 0),
    )
    info.compress_type = zipfile.ZIP_DEFLATED
    info.create_system = 3
    info.external_attr = 0o100644 << 16
    return info


def export_watertight_3mf(placed: dict[str, cq.Shape]) -> None:
    """Write the guarded plate as three named, manifold 3MF objects.

    CadQuery's generic 3MF writer duplicates coincident boundary vertices.
    The resulting file slices, but Bambu Studio reports every duplicated
    boundary as an open mesh edge.  The plate STL is already validated as
    watertight, so split its three positioned shells and place those exact
    meshes into a standard 3MF scene.
    """
    plate_mesh = load_mesh(PLATE_STL_PATH)
    available = list(plate_mesh.split(only_watertight=False))
    if len(available) != len(placed):
        raise RuntimeError(
            f"{PLATE_STL_PATH.name} has {len(available)} components; "
            f"expected {len(placed)}"
        )

    named_meshes: list[tuple[str, trimesh.Trimesh]] = []
    for name, shape in placed.items():
        bounds = shape.BoundingBox()
        expected = np.asarray(
            [
                [bounds.xmin, bounds.ymin, bounds.zmin],
                [bounds.xmax, bounds.ymax, bounds.zmax],
            ],
            dtype=float,
        )
        match_index = min(
            range(len(available)),
            key=lambda index: float(
                np.max(np.abs(available[index].bounds - expected))
            ),
        )
        mesh = available.pop(match_index)
        bounds_error = float(np.max(np.abs(mesh.bounds - expected)))
        if bounds_error > EXPORT_TOLERANCE_MM + 0.05:
            raise RuntimeError(
                f"Could not match {name} to a plate mesh; "
                f"bounds differ by {bounds_error:.3f} mm"
            )
        if not mesh.is_watertight or not mesh.is_winding_consistent:
            raise RuntimeError(
                f"{name} is not suitable for a warning-free 3MF"
            )
        mesh = canonicalize_mesh(mesh)
        named_meshes.append((name, mesh))

    model_namespace = (
        "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
    )
    content_namespace = (
        "http://schemas.openxmlformats.org/package/2006/content-types"
    )
    relationships_namespace = (
        "http://schemas.openxmlformats.org/package/2006/relationships"
    )
    ET.register_namespace("", model_namespace)
    model = ET.Element(
        f"{{{model_namespace}}}model",
        {
            "unit": "millimeter",
            "{http://www.w3.org/XML/1998/namespace}lang": "en-US",
        },
    )
    resources = ET.SubElement(
        model,
        f"{{{model_namespace}}}resources",
    )
    for object_id, (name, mesh) in enumerate(named_meshes, start=1):
        object_element = ET.SubElement(
            resources,
            f"{{{model_namespace}}}object",
            {
                "id": str(object_id),
                "type": "model",
                "name": name,
            },
        )
        mesh_element = ET.SubElement(
            object_element,
            f"{{{model_namespace}}}mesh",
        )
        vertices_element = ET.SubElement(
            mesh_element,
            f"{{{model_namespace}}}vertices",
        )
        for vertex in mesh.vertices:
            ET.SubElement(
                vertices_element,
                f"{{{model_namespace}}}vertex",
                {
                    "x": f"{float(vertex[0]):.9g}",
                    "y": f"{float(vertex[1]):.9g}",
                    "z": f"{float(vertex[2]):.9g}",
                },
            )
        triangles_element = ET.SubElement(
            mesh_element,
            f"{{{model_namespace}}}triangles",
        )
        for face in mesh.faces:
            ET.SubElement(
                triangles_element,
                f"{{{model_namespace}}}triangle",
                {
                    "v1": str(int(face[0])),
                    "v2": str(int(face[1])),
                    "v3": str(int(face[2])),
                },
            )

    build = ET.SubElement(model, f"{{{model_namespace}}}build")
    for object_id in range(1, len(named_meshes) + 1):
        ET.SubElement(
            build,
            f"{{{model_namespace}}}item",
            {"objectid": str(object_id)},
        )
    model_xml = ET.tostring(
        model,
        encoding="utf-8",
        xml_declaration=True,
    )

    ET.register_namespace("", relationships_namespace)
    relationships = ET.Element(
        f"{{{relationships_namespace}}}Relationships"
    )
    ET.SubElement(
        relationships,
        f"{{{relationships_namespace}}}Relationship",
        {
            "Target": "/3D/3dmodel.model",
            "Id": "rel0",
            "Type": (
                "http://schemas.microsoft.com/"
                "3dmanufacturing/2013/01/3dmodel"
            ),
        },
    )
    relationships_xml = ET.tostring(
        relationships,
        encoding="utf-8",
        xml_declaration=True,
    )

    ET.register_namespace("", content_namespace)
    content_types = ET.Element(f"{{{content_namespace}}}Types")
    for extension, content_type in (
        (
            "model",
            "application/vnd.ms-package."
            "3dmanufacturing-3dmodel+xml",
        ),
        (
            "rels",
            "application/vnd.openxmlformats-package.relationships+xml",
        ),
    ):
        ET.SubElement(
            content_types,
            f"{{{content_namespace}}}Default",
            {
                "Extension": extension,
                "ContentType": content_type,
            },
        )
    content_types_xml = ET.tostring(
        content_types,
        encoding="utf-8",
        xml_declaration=True,
    )

    with zipfile.ZipFile(
        PLATE_3MF_PATH,
        mode="w",
        compression=zipfile.ZIP_DEFLATED,
        compresslevel=9,
    ) as archive:
        archive.writestr(
            deterministic_zip_info("[Content_Types].xml"),
            content_types_xml,
        )
        archive.writestr(
            deterministic_zip_info("_rels/.rels"),
            relationships_xml,
        )
        archive.writestr(
            deterministic_zip_info("3D/3dmodel.model"),
            model_xml,
        )


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


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) != expected_meshes:
        raise RuntimeError(
            f"{path.name} has {len(build_items)} build items; "
            f"expected {expected_meshes}"
        )

    raw_meshes: list[dict[str, int]] = []
    for index, mesh in enumerate(meshes):
        vertices = mesh.findall("./m:vertices/m:vertex", namespace)
        triangles = mesh.findall("./m:triangles/m:triangle", namespace)
        edge_counts: Counter[tuple[int, int]] = Counter()
        for triangle in triangles:
            indices = tuple(
                int(triangle.attrib[key]) for key in ("v1", "v2", "v3")
            )
            if min(indices) < 0 or max(indices) >= len(vertices):
                raise RuntimeError(
                    f"{path.name} mesh {index} has an invalid vertex index"
                )
            edge_counts.update(
                (
                    tuple(sorted((indices[0], indices[1]))),
                    tuple(sorted((indices[1], indices[2]))),
                    tuple(sorted((indices[2], indices[0]))),
                )
            )
        boundary_edges = sum(
            count == 1 for count in edge_counts.values()
        )
        nonmanifold_edges = sum(
            count > 2 for count in edge_counts.values()
        )
        if boundary_edges or nonmanifold_edges:
            raise RuntimeError(
                f"{path.name} mesh {index} has {boundary_edges} boundary "
                f"edges and {nonmanifold_edges} nonmanifold edges"
            )
        raw_meshes.append(
            {
                "vertices": len(vertices),
                "triangles": len(triangles),
                "boundary_edges": boundary_edges,
                "nonmanifold_edges": nonmanifold_edges,
            }
        )

    return {
        "file": path.name,
        "bytes": path.stat().st_size,
        "unit": root.attrib.get("unit"),
        "mesh_resources": len(meshes),
        "build_items": len(build_items),
        "raw_meshes": raw_meshes,
        "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(
    window_stencil: cq.Shape,
    opening_gauge: cq.Shape,
    photo_scale: cq.Shape,
    placed: dict[str, cq.Shape],
) -> dict[str, object]:
    printable = {
        "window-marker-stencil": window_stencil,
        "opening-gauge": opening_gauge,
        "photo-scale": photo_scale,
    }
    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"
                )

    if WINDOW_GRID_MM != 50.8:
        raise RuntimeError("Window stencil grid is not exactly 50.8 mm")
    if WINDOW_APERTURE_DIAMETER_MM < 6.35:
        raise RuntimeError(
            "Window stencil aperture is below the 1/4-inch nominal target"
        )
    if PHOTO_SCALE_END_X_MM - PHOTO_SCALE_START_X_MM != 100.0:
        raise RuntimeError("Photo scale baseline is not exactly 100 mm")
    if len(OPENING_DIAMETERS_MM) != 6:
        raise RuntimeError("Opening gauge does not have six target sizes")
    if len(set(OPENING_DIAMETERS_MM)) != len(OPENING_DIAMETERS_MM):
        raise RuntimeError("Opening gauge sizes are not unique")

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

    plate = make_compound(list(placed.values()))
    return {
        "printable_part_bounds_mm": part_bounds,
        "x1c_placed_bounds_mm": placed_bounds,
        "x1c_plate_envelope_mm": bounds_report(plate),
        "window_marker_stencil": {
            "grid_spacing_x": WINDOW_GRID_MM,
            "grid_spacing_y": WINDOW_GRID_MM,
            "columns": WINDOW_GRID_COLUMNS,
            "rows": WINDOW_GRID_ROWS,
            "marker_positions": (
                WINDOW_GRID_COLUMNS * WINDOW_GRID_ROWS
            ),
            "aperture_diameter": WINDOW_APERTURE_DIAMETER_MM,
            "ring_outer_diameter": WINDOW_RING_OUTER_DIAMETER_MM,
            "printed_tool_is_window_treatment": False,
        },
        "opening_gauge": {
            "nominal_hole_diameters": list(OPENING_DIAMETERS_MM),
            "species_assignments": None,
            "measurement_only": True,
        },
        "photo_scale": {
            "nominal_baseline": PHOTO_SCALE_BASELINE_MM,
            "tick_interval": PHOTO_SCALE_TICK_MM,
            "orientation_arrow": True,
            "lanyard_hole_diameter": PHOTO_SCALE_LANYARD_DIAMETER_MM,
        },
        "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:
    triangles = mesh.vertices[mesh.faces]
    collection = Poly3DCollection(
        triangles,
        facecolor=color,
        edgecolor="#16362c",
        linewidth=0.025,
        alpha=alpha,
    )
    return collection


def set_equal_3d_limits(
    axis: plt.Axes,
    meshes: list[trimesh.Trimesh],
    pad_ratio: float = 0.08,
) -> None:
    bounds = np.vstack([mesh.bounds for mesh in meshes])
    minimum = bounds.min(axis=0)
    maximum = bounds.max(axis=0)
    center = (minimum + maximum) / 2.0
    spans = maximum - minimum
    radius = max(float(spans.max()) / 2.0, 1.0)
    radius *= 1.0 + pad_ratio
    axis.set_xlim(center[0] - radius, center[0] + radius)
    axis.set_ylim(center[1] - radius, center[1] + radius)
    axis.set_zlim(
        min(-radius * 0.08, minimum[2] - radius * 0.08),
        center[2] + radius,
    )


def style_3d_axis(axis: plt.Axes) -> None:
    axis.set_axis_off()
    axis.set_facecolor("#f6f0e4")


def render_tools_preview(
    window_stencil: cq.Shape,
    opening_gauge: cq.Shape,
    photo_scale: cq.Shape,
) -> None:
    tools = (
        (
            "50.8 mm window-marker stencil",
            "Positions twelve exterior marks",
            window_stencil,
            "#2d7a5f",
        ),
        (
            "Nominal opening gauge",
            "25 / 28 / 32 / 35 / 38 / 40 mm",
            opening_gauge,
            "#2d7a5f",
        ),
        (
            "100 mm field-photo scale",
            "Metric ticks + orientation arrow",
            photo_scale,
            "#c28d3f",
        ),
    )
    figure = plt.figure(figsize=(16, 6.7), facecolor="#f6f0e4")
    for index, (title, subtitle, shape, color) in enumerate(tools, start=1):
        axis = figure.add_subplot(1, 3, index, projection="3d")
        mesh = shape_mesh(to_origin(shape))
        axis.add_collection3d(mesh_collection(mesh, color))
        set_equal_3d_limits(axis, [mesh], pad_ratio=0.05)
        axis.view_init(elev=64, azim=-66)
        style_3d_axis(axis)
        axis.set_title(
            title + "\n" + subtitle,
            fontsize=11.5,
            fontweight="bold",
            color="#173f31",
            pad=1,
        )

    figure.suptitle(
        "Backyard Habitat Utility Pack — three useful tools, one plate",
        fontsize=19,
        fontweight="bold",
        color="#173f31",
        y=0.98,
    )
    figure.text(
        0.5,
        0.025,
        (
            "Flat, unsplit, support-free geometry. "
            "Import at 100%; all dimensions are nominal millimetres."
        ),
        ha="center",
        fontsize=10,
        color="#4c5b54",
    )
    figure.tight_layout(rect=(0.0, 0.06, 1.0, 0.91), w_pad=0.2)
    figure.savefig(
        TOOLS_PREVIEW_PATH,
        dpi=150,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def render_plate_preview(placed: dict[str, cq.Shape]) -> None:
    figure = plt.figure(figsize=(10, 11), facecolor="#f6f0e4")
    axis = figure.add_subplot(111, projection="3d")

    plate_surface = np.asarray(
        [
            [0.0, 0.0, -0.28],
            [X1C_X_MM, 0.0, -0.28],
            [X1C_X_MM, X1C_Y_MM, -0.28],
            [0.0, X1C_Y_MM, -0.28],
        ]
    )
    axis.add_collection3d(
        Poly3DCollection(
            [plate_surface],
            facecolor="#f8f6ee",
            edgecolor="#8f9a92",
            linewidth=1.2,
            alpha=0.18,
        )
    )
    exclusion = np.asarray(
        [
            [0.0, 0.0, -0.20],
            [X1C_EXCLUSION_X_MM, 0.0, -0.20],
            [X1C_EXCLUSION_X_MM, X1C_EXCLUSION_Y_MM, -0.20],
            [0.0, X1C_EXCLUSION_Y_MM, -0.20],
        ]
    )
    axis.add_collection3d(
        Poly3DCollection(
            [exclusion],
            facecolor="#d79b91",
            edgecolor="#a95d54",
            linewidth=0.9,
            alpha=0.35,
        )
    )

    colors = {
        "window-marker-stencil": "#2d7a5f",
        "opening-gauge": "#1d624a",
        "photo-scale": "#c28d3f",
    }
    meshes: list[trimesh.Trimesh] = []
    for name, shape in placed.items():
        mesh = shape_mesh(shape)
        meshes.append(mesh)
        axis.add_collection3d(mesh_collection(mesh, colors[name]))

    plate_mesh = trimesh.Trimesh(
        vertices=np.asarray(
            [
                [0.0, 0.0, -0.28],
                [X1C_X_MM, 0.0, -0.28],
                [X1C_X_MM, X1C_Y_MM, -0.28],
                [0.0, X1C_Y_MM, -0.28],
            ]
        ),
        faces=np.asarray([[0, 1, 2], [0, 2, 3]]),
        process=False,
    )
    set_equal_3d_limits(axis, meshes + [plate_mesh], pad_ratio=0.03)
    axis.view_init(elev=72, azim=-66)
    style_3d_axis(axis)
    axis.set_title(
        "Guarded Bambu Lab X1C one-plate layout",
        fontsize=18,
        fontweight="bold",
        color="#173f31",
        pad=10,
    )
    figure.text(
        0.5,
        0.045,
        (
            "Three independent tools • 204 × 236 × 2.4 mm digital envelope "
            "• conservative cutter corner kept clear"
        ),
        ha="center",
        fontsize=10,
        color="#4c5b54",
    )
    figure.tight_layout(rect=(0.0, 0.07, 1.0, 0.94))
    figure.savefig(
        PLATE_PREVIEW_PATH,
        dpi=150,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def render_use_guide() -> None:
    figure, axes = plt.subplots(
        1,
        3,
        figsize=(17, 5.6),
        facecolor="#f6f0e4",
    )
    titles = (
        "1. Lay out exterior marks",
        "2. Measure an empty box",
        "3. Add scale to a field photo",
    )
    subtitles = (
        "The jig repeats a 50.8 mm grid; the marks treat the glass.",
        "Compare nominal openings, then use a current local species plan.",
        "Place the ruler beside sign; never handle wildlife or nest contents.",
    )
    for axis, title, subtitle in zip(axes, titles, subtitles):
        axis.set_xlim(0.0, 1.0)
        axis.set_ylim(0.0, 1.0)
        axis.set_aspect("equal")
        axis.axis("off")
        axis.set_title(
            title,
            fontsize=13,
            fontweight="bold",
            color="#173f31",
            pad=8,
        )
        axis.text(
            0.5,
            0.03,
            subtitle,
            ha="center",
            va="bottom",
            fontsize=8.6,
            color="#4c5b54",
            wrap=True,
        )

    # Exterior pane and a twelve-dot, two-inch layout.
    axes[0].add_patch(
        Rectangle(
            (0.12, 0.17),
            0.76,
            0.70,
            facecolor="#d9eceb",
            edgecolor="#547676",
            linewidth=2.0,
        )
    )
    for x_value in (0.25, 0.42, 0.59, 0.76):
        for y_value in (0.29, 0.49, 0.69):
            axes[0].add_patch(
                Circle(
                    (x_value, y_value),
                    0.018,
                    facecolor="#ffffff",
                    edgecolor="#173f31",
                    linewidth=1.0,
                )
            )
    axes[0].annotate(
        "≤ 50.8 mm",
        xy=(0.42, 0.77),
        xytext=(0.59, 0.77),
        arrowprops={
            "arrowstyle": "<->",
            "color": "#c28d3f",
            "linewidth": 1.8,
        },
        ha="center",
        va="center",
        fontsize=9,
        color="#6a382f",
    )
    axes[0].text(
        0.50,
        0.91,
        "OUTSIDE SURFACE",
        ha="center",
        fontsize=9,
        fontweight="bold",
        color="#173f31",
    )

    # Simple inactive-box elevation and translucent gauge overlay.
    axes[1].add_patch(
        Rectangle(
            (0.23, 0.17),
            0.54,
            0.54,
            facecolor="#c69a63",
            edgecolor="#694b2e",
            linewidth=1.8,
        )
    )
    axes[1].add_patch(
        Polygon(
            [(0.18, 0.71), (0.50, 0.90), (0.82, 0.71)],
            closed=True,
            facecolor="#8c6038",
            edgecolor="#694b2e",
            linewidth=1.8,
        )
    )
    axes[1].add_patch(
        Circle(
            (0.50, 0.55),
            0.12,
            facecolor="#173f31",
            edgecolor="#102a21",
        )
    )
    axes[1].add_patch(
        Rectangle(
            (0.28, 0.39),
            0.44,
            0.30,
            facecolor="#2d7a5f",
            edgecolor="#173f31",
            linewidth=1.6,
            alpha=0.48,
        )
    )
    axes[1].add_patch(
        Circle(
            (0.50, 0.55),
            0.13,
            facecolor="#f6f0e4",
            edgecolor="#173f31",
            linewidth=1.3,
            alpha=0.92,
        )
    )
    axes[1].text(
        0.50,
        0.27,
        "MEASURE ONLY",
        ha="center",
        fontsize=9,
        fontweight="bold",
        color="#6a382f",
    )

    # Track/leaf sign beside a metric photo scale.
    axes[2].add_patch(
        Polygon(
            [
                (0.22, 0.36),
                (0.34, 0.61),
                (0.47, 0.70),
                (0.53, 0.57),
                (0.46, 0.37),
                (0.33, 0.27),
            ],
            closed=True,
            facecolor="#769765",
            edgecolor="#395c36",
            linewidth=1.6,
        )
    )
    axes[2].plot(
        [0.29, 0.50],
        [0.33, 0.62],
        color="#395c36",
        linewidth=1.4,
    )
    axes[2].add_patch(
        Rectangle(
            (0.18, 0.76),
            0.63,
            0.08,
            facecolor="#c28d3f",
            edgecolor="#694b2e",
            linewidth=1.3,
        )
    )
    axes[2].add_patch(
        Polygon(
            [(0.81, 0.72), (0.93, 0.80), (0.81, 0.88)],
            closed=True,
            facecolor="#c28d3f",
            edgecolor="#694b2e",
            linewidth=1.3,
        )
    )
    for x_value in np.linspace(0.23, 0.78, 11):
        axes[2].plot(
            [x_value, x_value],
            [0.76, 0.80],
            color="#173f31",
            linewidth=0.8,
        )
    axes[2].text(
        0.50,
        0.91,
        "100 mm",
        ha="center",
        fontsize=9,
        fontweight="bold",
        color="#173f31",
    )

    figure.suptitle(
        "Three helpers — each tool has a boundary",
        fontsize=18,
        fontweight="bold",
        color="#173f31",
        y=0.98,
    )
    figure.text(
        0.5,
        0.015,
        (
            "Digitally validated geometry only. "
            "Verify the physical print before relying on any dimension."
        ),
        ha="center",
        fontsize=9,
        color="#6a382f",
    )
    figure.tight_layout(rect=(0.0, 0.06, 1.0, 0.90), w_pad=1.0)
    figure.savefig(
        USE_GUIDE_PATH,
        dpi=150,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    plt.close(figure)


def write_validation_report(
    window_stencil: cq.Shape,
    opening_gauge: cq.Shape,
    photo_scale: cq.Shape,
    placed: dict[str, cq.Shape],
) -> dict[str, object]:
    geometry = validate_geometry(
        window_stencil,
        opening_gauge,
        photo_scale,
        placed,
    )
    three_mf_sha256 = hashlib.sha256(
        PLATE_3MF_PATH.read_bytes()
    ).hexdigest().upper()
    if three_mf_sha256 != SLICED_3MF_SHA256:
        raise RuntimeError(
            f"{PLATE_3MF_PATH.name} SHA-256 {three_mf_sha256} does not "
            "match the slice-validated artifact "
            f"{SLICED_3MF_SHA256}"
        )
    report = {
        "schema_version": 1,
        "design": "Make Birdhouses Backyard Habitat Utility Pack",
        "revision": "v1",
        "units": "millimetres",
        "intended_use": (
            "Digitally validated layout and measurement tools for "
            "window-marker installation, empty-box opening comparison, "
            "and scaled nature-observation photographs"
        ),
        "digital_validation_only": True,
        "physical_print_tested": False,
        "field_tested": False,
        "window_treatment_effectiveness_tested": False,
        "wildlife_contact_intended": False,
        "geometry": geometry,
        "meshes": {
            "window_marker_stencil": mesh_report(
                WINDOW_STENCIL_STL_PATH,
                1,
            ),
            "opening_gauge": mesh_report(
                OPENING_GAUGE_STL_PATH,
                1,
            ),
            "photo_scale": mesh_report(PHOTO_SCALE_STL_PATH, 1),
            "x1c_plate": mesh_report(PLATE_STL_PATH, 3),
        },
        "three_mf": validate_3mf(PLATE_3MF_PATH, 3),
        "slicer_validation": {
            "artifact_sha256": three_mf_sha256,
            "application": "Bambu Studio",
            "version": "2.8.1.55",
            "date": "2026-07-27",
            "printer": "Bambu Lab X1 Carbon",
            "build_plate": "Textured PEI Plate",
            "nozzle_diameter_mm": 0.4,
            "process": "0.20mm Standard @BBL X1C",
            "filament_profile": "PLA Basic",
            "layer_height_mm": 0.2,
            "initial_layer_height_mm": 0.2,
            "layer_count": 12,
            "supports_generated": False,
            "mesh_warnings": 0,
            "bounds_warnings": 0,
            "filament_length_m": 14.10,
            "filament_mass_g": 42.73,
            "prepare_time": "7m15s",
            "model_printing_time": "1h18m",
            "total_estimated_time": "1h25m",
            "physical_print_performed": False,
        },
        "checks": {
            "three_printable_components_are_watertight": True,
            "three_mf_raw_meshes_have_no_boundary_edges": True,
            "three_mf_raw_meshes_have_no_nonmanifold_edges": 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_three_solids": True,
            "window_grid_is_50p8_mm_in_both_axes": True,
            "window_stencil_has_twelve_marker_positions": True,
            "opening_gauge_has_six_unique_nominal_sizes": True,
            "photo_scale_has_100_mm_nominal_baseline": True,
            "no_supports_are_geometrically_required": True,
            "bambu_studio_x1c_slice_completed": True,
            "slice_record_bound_to_exact_3mf_sha256": True,
            "bambu_studio_slice_has_no_mesh_or_bounds_warnings": True,
            "bambu_studio_slice_generated_no_supports": True,
        },
        "completed_release_gates": [
            (
                "Bambu Studio 2.8.1.55 X1C slice completed with three "
                "separate objects, no mesh or bounds warnings, and no "
                "generated supports"
            ),
        ],
        "physical_release_gates": [
            "one complete print and inspection for sharp edges or defects",
            "caliper verification of the 50.8 mm stencil pitch",
            "caliper verification of all six opening-gauge diameters",
            "ruler verification of the photo scale's 100 mm baseline",
            "test stencil use on a disposable non-window surface",
            "confirm selected marker product, size, visibility, and exterior use",
            "confirm the full window receives a current effective treatment",
            "bench-only opening comparison on an empty or new nest box",
            "field-photo trial without touching wildlife or nest contents",
            "cleaning, heat, sunlight, and storage-deformation observations",
            "field review before promotional safety or conservation claims",
        ],
        "not_validated": [
            "physical X1C print",
            "dimensional accuracy from any printer or filament",
            "slicer estimates outside the recorded X1C profile",
            "window-treatment effectiveness",
            "marker adhesion, visibility, durability, or glass compatibility",
            "species suitability of any entrance diameter",
            "accuracy as a precision metrology instrument",
            "wildlife identification",
            "use at or near an active nest",
            "weather resistance",
            "conservation outcome",
        ],
    }
    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"]
    print("Backyard Habitat Utility Pack generated")
    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: Bambu Studio slice validated; physical checks pending"
    )


def main() -> None:
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    window_stencil = make_window_marker_stencil()
    opening_gauge = make_opening_gauge()
    photo_scale = make_photo_scale()
    placed = place_parts(window_stencil, opening_gauge, photo_scale)

    export_models(window_stencil, opening_gauge, photo_scale, placed)
    render_tools_preview(window_stencil, opening_gauge, photo_scale)
    render_plate_preview(placed)
    render_use_guide()
    report = write_validation_report(
        window_stencil,
        opening_gauge,
        photo_scale,
        placed,
    )
    package_release()
    print_report(report)


if __name__ == "__main__":
    warnings.filterwarnings(
        "ignore",
        message="face_normals all zero, ignoring!",
    )
    main()
