#!/usr/bin/env python3
"""
Generate a 3D-printable Möbius strip STL with honeycomb through-holes.

Uses:
  - numpy
  - trimesh
  - manifold3d (for boolean cuts)

Examples:
  python3 mobius_honeycomb.py
  python3 mobius_honeycomb.py --preview
  python3 mobius_honeycomb.py --preview --no-export
"""

from __future__ import annotations

import argparse
import math
from dataclasses import dataclass
from typing import Iterable, List, Sequence, Tuple

import numpy as np

try:
    import trimesh
except ImportError as exc:  # pragma: no cover
    raise SystemExit("Missing dependency: trimesh") from exc

try:
    import manifold3d as m3d
except ImportError as exc:  # pragma: no cover
    raise SystemExit("Missing dependency: manifold3d") from exc


# ========================
# Tweakable dimensions (mm)
# ========================
RADIUS_MM = 65.0
STRIP_WIDTH_MM = 42.0
STRIP_THICKNESS_MM = 8.0
HEX_CELL_FLAT_TO_FLAT_MM = 6.0    # opening size (hole), measured flat-to-flat
HEX_WALL_THICKNESS_MM = 1.8       # requested minimum wall thickness between holes

# Meshing / pattern controls
LOOP_SEGMENTS = 320               # smoothness around the loop
RECT_WIDTH_SAMPLES = 18           # subdivisions across strip width on the cross-section perimeter
RECT_THICKNESS_SAMPLES = 6        # subdivisions across strip thickness on the cross-section perimeter
EDGE_MARGIN_MM = 0.0              # set to 0 for clipped edge cells / minimal boundary rim
CUTTER_DEPTH_MARGIN_MM = 3.0      # extra depth beyond strip thickness for through-hole cutters
BOOLEAN_UNION_BATCH_SIZE = 2      # pairwise tree union (leave at 2)
ALLOW_PARTIAL_EDGE_CELLS = True   # lets hex openings clip the strip boundary like a wireframe mesh

# Structural lattice mode (honeycomb struts themselves form the Möbius strip)
WIRE_HONEYCOMB_LATTICE_MODE = True
WIRE_STRUT_DIAMETER_MM = 2.4      # printable strut diameter for the lattice (rod-like, not plate walls)
WIRE_STRUT_SECTIONS = 14          # cylinder roundness
WIRE_USE_CAPSULE_STRUTS = False   # rounded-end struts for a softer, more organic lattice
WIRE_CAPSULE_LAT_LONG_COUNT = (10, 18)  # (latitude, longitude) resolution for capsules
WIRE_DUPLICATE_SURFACES_AND_CONNECT = False  # single honeycomb surface lattice (no second stacked layer)
WIRE_STACK_LAYER_COUNT = 2        # 2 = previous look (top/bottom only); 3+ makes the connector region denser
WIRE_CONNECTOR_STRUT_DIAMETER_SCALE = 0.82  # connector struts can be slightly slimmer than surface struts
WIRE_INTERLACED_DIAGONAL_CONNECTORS = True  # diagonal cross-links between top/bottom layers
WIRE_SECOND_DIAGONAL_FAMILY = False  # add the opposite diagonal too (X-bracing / denser 3D weave)
WIRE_SECOND_DIAGONAL_DIAMETER_SCALE = 0.78
WIRE_ADD_BOUNDARY_VERTICAL_CONNECTORS = True  # keep boundaries stiff where clipped cells create open edges
WIRE_ADD_NODE_SPHERES = True      # smooth/round joints
WIRE_NODE_SPHERE_SUBDIVISIONS = 1
WIRE_MIN_SEGMENT_MM = 0.15        # skip tiny clipped fragments

# Optional decorative nod plaque (simple embossed "3000")
ADD_ENDGAME_NOD_PLAQUE = True
PLAQUE_TEXT = "Mobius Strip"
PLAQUE_WIDTH_MM = 28.0
PLAQUE_HEIGHT_MM = 16.0
PLAQUE_THICKNESS_MM = 1.3
PLAQUE_TEXT_HEIGHT_MM = 12.0
PLAQUE_TEXT_STROKE_MM = 1.0
PLAQUE_TEXT_EMBOSS_MM = 0.7   # used as engraved text depth
PLAQUE_MARGIN_MM = 1.2
PLAQUE_OFFSET_FROM_STRIP_MM = 4.5
PLAQUE_STEM_DIAMETER_MM = 1.6
PLAQUE_ATTACH_U_DEG = 38.0

# Lattice style: `False` gives the image-like honeycomb surface sheet (no shell skins / no internal infill)
EMBEDDED_HONEYCOMB_MODE = False
SHELL_FACE_SKIN_MM = 1.4          # thickness of outer/inner broad-face skins
SHELL_SIDE_SKIN_MM = 1.6          # side rim thickness along strip width edges
FACE_WINDOW_HOLE_SCALE = 1.00     # scale of honeycomb openings on outer faces
FACE_WINDOW_BOTTOM_S_SHIFT = 0.5  # shift bottom-face honeycomb by N columns to expose 3D effect
CORE_HOLE_SCALE = 0.82            # smaller core holes => stronger / less porous internal lattice
CORE_S_SHIFT = 0.25               # shift core lattice relative to outer-face windows
CORE_TO_SHELL_OVERLAP_MM = 0.20   # slight overlap to ensure boolean union merges core to shell
CORE_LAYER_COUNT = 3              # number of internal honeycomb layers inside the cavity
CORE_LAYER_THICKNESS_MM = 1.6     # thinner than cavity => visible hollow interior between layers
CORE_CENTER_LAYER_HOLE_SCALE = 0.75  # center layer slightly stronger than outer core layers

# Output / UX
OUTPUT_FILENAME = "mobius_honeycomb.stl"
PREVIEW_DEFAULT = False


EPS = 1e-9
SQRT3 = math.sqrt(3.0)


@dataclass
class HoneycombLayout:
    hole_side: float
    hole_apothem: float
    pitch_side: float
    pitch_apothem: float
    actual_wall: float
    dx: float
    dy: float
    y_limit: float
    n_cols: int
    n_holes: int
    centers: np.ndarray  # (N, 2) -> [s, v]


def normalize(v: np.ndarray) -> np.ndarray:
    n = np.linalg.norm(v)
    if n < EPS:
        raise ValueError("Cannot normalize near-zero vector")
    return v / n


def rectangle_perimeter_samples(width: float, thickness: float, n_w: int, n_t: int) -> np.ndarray:
    """
    Return ordered perimeter samples of a rectangle in (v, z), centered at origin.

    The order is chosen so a 180-degree rotation maps index i to i + N/2 mod N.
    """
    n_w = max(1, int(n_w))
    n_t = max(1, int(n_t))

    hw = width * 0.5
    ht = thickness * 0.5

    pts: List[Tuple[float, float]] = []

    # Top edge: left -> right
    for i in range(n_w):
        t = i / n_w
        pts.append((-hw + 2.0 * hw * t, +ht))

    # Right edge: top -> bottom
    for i in range(n_t):
        t = i / n_t
        pts.append((+hw, +ht - 2.0 * ht * t))

    # Bottom edge: right -> left
    for i in range(n_w):
        t = i / n_w
        pts.append((+hw - 2.0 * hw * t, -ht))

    # Left edge: bottom -> top
    for i in range(n_t):
        t = i / n_t
        pts.append((-hw, -ht + 2.0 * ht * t))

    arr = np.asarray(pts, dtype=np.float64)
    if (len(arr) % 2) != 0:
        raise ValueError("Perimeter sample count must be even for Möbius seam shift")
    return arr


def mobius_frame(u: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """
    Returns (centerline point, tangent, width_dir, thickness_dir).
    width_dir / thickness_dir rotate by u/2 to produce the Möbius twist.
    """
    cu, su = math.cos(u), math.sin(u)
    half = 0.5 * u
    ch, sh = math.cos(half), math.sin(half)

    radial = np.array([cu, su, 0.0], dtype=np.float64)
    tangent = np.array([-su, cu, 0.0], dtype=np.float64)
    z_axis = np.array([0.0, 0.0, 1.0], dtype=np.float64)

    center = RADIUS_MM * radial
    width_dir = ch * radial + sh * z_axis
    thickness_dir = -sh * radial + ch * z_axis
    return center, tangent, normalize(width_dir), normalize(thickness_dir)


def mobius_point(u: float, v: float, z: float) -> np.ndarray:
    center, _tangent, width_dir, thickness_dir = mobius_frame(u)
    return center + v * width_dir + z * thickness_dir


def build_mobius_solid_mesh(
    width: float = STRIP_WIDTH_MM,
    thickness: float = STRIP_THICKNESS_MM,
    z_center: float = 0.0,
) -> trimesh.Trimesh:
    ring_vz = rectangle_perimeter_samples(
        width,
        thickness,
        RECT_WIDTH_SAMPLES,
        RECT_THICKNESS_SAMPLES,
    )
    ring_count = ring_vz.shape[0]
    seam_shift = ring_count // 2

    us = np.linspace(0.0, 2.0 * math.pi, LOOP_SEGMENTS, endpoint=False, dtype=np.float64)

    vertices = np.zeros((LOOP_SEGMENTS, ring_count, 3), dtype=np.float64)
    for i, u in enumerate(us):
        center, _tangent, width_dir, thickness_dir = mobius_frame(float(u))
        # Vectorized placement for the entire cross-section ring.
        vertices[i] = (
            center[None, :]
            + ring_vz[:, [0]] * width_dir[None, :]
            + (ring_vz[:, [1]] + z_center) * thickness_dir[None, :]
        )

    faces: List[Tuple[int, int, int]] = []

    def vid(i: int, j: int) -> int:
        return i * ring_count + j

    for i in range(LOOP_SEGMENTS):
        i_next = (i + 1) % LOOP_SEGMENTS
        shift = seam_shift if i == (LOOP_SEGMENTS - 1) else 0
        for j in range(ring_count):
            j_next = (j + 1) % ring_count
            a = vid(i, j)
            b = vid(i, j_next)
            c = vid(i_next, (j_next + shift) % ring_count)
            d = vid(i_next, (j + shift) % ring_count)
            faces.append((a, b, c))
            faces.append((a, c, d))

    mesh = trimesh.Trimesh(
        vertices=vertices.reshape((-1, 3)),
        faces=np.asarray(faces, dtype=np.int64),
        process=False,
        validate=False,
    )
    mesh.remove_unreferenced_vertices()
    mesh.fix_normals()
    if mesh.volume < 0:
        mesh.invert()
    return mesh


def manifold_from_trimesh(mesh: trimesh.Trimesh):
    verts = np.asarray(mesh.vertices, dtype=np.float32)
    faces = np.asarray(mesh.faces, dtype=np.uint32)

    if not hasattr(m3d, "Manifold"):
        raise RuntimeError("manifold3d.Manifold is not available")
    if not hasattr(m3d, "Mesh"):
        raise RuntimeError("manifold3d.Mesh is not available")

    mesh_obj = None
    ctor_errors = []

    ctor_attempts = [
        lambda: m3d.Mesh(vert_properties=verts, tri_verts=faces),
        lambda: m3d.Mesh(verts, faces),
        lambda: m3d.Mesh(vertices=verts, triangles=faces),
    ]

    for attempt in ctor_attempts:
        try:
            mesh_obj = attempt()
            break
        except TypeError as exc:
            ctor_errors.append(str(exc))

    if mesh_obj is None:
        raise RuntimeError(
            "Unable to construct manifold3d.Mesh from trimesh data. "
            f"Constructor attempts failed: {ctor_errors}"
        )

    try:
        return m3d.Manifold(mesh_obj)
    except TypeError:
        # Some builds may expose a named constructor.
        if hasattr(m3d.Manifold, "from_mesh"):
            return m3d.Manifold.from_mesh(mesh_obj)
        raise


def trimesh_from_manifold(manifold_obj) -> trimesh.Trimesh:
    if hasattr(manifold_obj, "to_mesh"):
        mm = manifold_obj.to_mesh()
    elif hasattr(manifold_obj, "as_mesh"):
        mm = manifold_obj.as_mesh()
    else:
        raise RuntimeError("Cannot convert manifold3d result back to mesh (missing to_mesh/as_mesh)")

    verts = None
    faces = None
    for name in ("vert_properties", "vertProperties", "vertices"):
        if hasattr(mm, name):
            verts = np.asarray(getattr(mm, name))
            break
    for name in ("tri_verts", "triVerts", "triangles", "faces"):
        if hasattr(mm, name):
            faces = np.asarray(getattr(mm, name))
            break

    if verts is None or faces is None:
        raise RuntimeError("Could not read vertices/faces from manifold3d mesh object")

    verts = np.asarray(verts, dtype=np.float64)
    if verts.ndim != 2 or verts.shape[1] < 3:
        raise RuntimeError(f"Unexpected manifold vertex array shape: {verts.shape}")

    faces = np.asarray(faces, dtype=np.int64)
    if faces.ndim != 2 or faces.shape[1] != 3:
        raise RuntimeError(f"Unexpected manifold face array shape: {faces.shape}")

    mesh = trimesh.Trimesh(vertices=verts[:, :3], faces=faces, process=False, validate=False)
    mesh.remove_unreferenced_vertices()
    mesh.fix_normals()
    if mesh.volume < 0:
        mesh.invert()
    return mesh


def manifold_union(a, b):
    if hasattr(a, "union"):
        return a.union(b)
    return a + b


def manifold_difference(a, b):
    if hasattr(a, "difference"):
        return a.difference(b)
    return a - b


def union_many(manifolds: Sequence) -> object:
    if not manifolds:
        raise ValueError("No manifolds to union")

    current = list(manifolds)
    while len(current) > 1:
        nxt = []
        for i in range(0, len(current), BOOLEAN_UNION_BATCH_SIZE):
            chunk = current[i : i + BOOLEAN_UNION_BATCH_SIZE]
            merged = chunk[0]
            for m in chunk[1:]:
                merged = manifold_union(merged, m)
            nxt.append(merged)
        current = nxt
    return current[0]


def fit_honeycomb_layout() -> HoneycombLayout:
    circumference = 2.0 * math.pi * RADIUS_MM
    hole_apothem = 0.5 * HEX_CELL_FLAT_TO_FLAT_MM
    hole_side = HEX_CELL_FLAT_TO_FLAT_MM / SQRT3

    pitch_apothem_nom = hole_apothem + 0.5 * HEX_WALL_THICKNESS_MM
    pitch_side_nom = 2.0 * pitch_apothem_nom / SQRT3
    dx_nom = 1.5 * pitch_side_nom

    n_cols = max(3, int(math.floor(circumference / dx_nom)))
    if n_cols <= 0:
        raise ValueError("Circumference too small for the requested honeycomb settings")

    while True:
        dx = circumference / n_cols
        pitch_side = (2.0 / 3.0) * dx
        pitch_apothem = 0.5 * SQRT3 * pitch_side
        actual_wall = 2.0 * (pitch_apothem - hole_apothem)
        if actual_wall > 0.2:
            break
        n_cols -= 1
        if n_cols < 3:
            raise ValueError("Hex holes are too large for the selected radius/circumference")

    dy = SQRT3 * pitch_side
    if ALLOW_PARTIAL_EDGE_CELLS:
        # Allow holes to clip the side boundary so the strip reads as all-honeycomb,
        # similar to a wireframe mesh truncated by the boundary curve.
        y_limit = (0.5 * STRIP_WIDTH_MM) + hole_apothem
    else:
        y_limit = (0.5 * STRIP_WIDTH_MM) - hole_apothem - max(EDGE_MARGIN_MM, 0.5 * HEX_WALL_THICKNESS_MM)
    if y_limit <= 0:
        raise ValueError(
            "Strip width is too small for the selected hex opening + wall + edge margin"
        )

    centers: List[Tuple[float, float]] = []
    for q in range(n_cols):
        x = (q + 0.5) * dx  # exact periodic fit around circumference
        y_offset = 0.5 * dy if (q & 1) else 0.0
        r_min = int(math.floor((-0.5 * STRIP_WIDTH_MM - y_offset) / dy)) - 1
        r_max = int(math.ceil((+0.5 * STRIP_WIDTH_MM - y_offset) / dy)) + 1
        for r in range(r_min, r_max + 1):
            y = y_offset + r * dy
            if abs(y) <= y_limit + EPS:
                centers.append((x, y))

    centers_arr = np.asarray(centers, dtype=np.float64)
    return HoneycombLayout(
        hole_side=hole_side,
        hole_apothem=hole_apothem,
        pitch_side=pitch_side,
        pitch_apothem=pitch_apothem,
        actual_wall=actual_wall,
        dx=dx,
        dy=dy,
        y_limit=y_limit,
        n_cols=n_cols,
        n_holes=len(centers),
        centers=centers_arr,
    )


def hex_polygon_flat_top(side: float) -> np.ndarray:
    r = 0.5 * SQRT3 * side
    return np.asarray(
        [
            (+side, 0.0),
            (+0.5 * side, +r),
            (-0.5 * side, +r),
            (-side, 0.0),
            (-0.5 * side, -r),
            (+0.5 * side, -r),
        ],
        dtype=np.float64,
    )


def mobius_point_wrapped_s(s_unwrapped: float, v: float, z: float = 0.0) -> np.ndarray:
    """
    Map an unwrapped strip coordinate (s, v, z) onto the Möbius strip.

    Every wrap by one circumference flips the local cross-section, matching Möbius
    identification: (s + L, v, z) ~ (s, -v, -z).
    """
    circumference = 2.0 * math.pi * RADIUS_MM
    k = math.floor(s_unwrapped / circumference)
    s = s_unwrapped - k * circumference
    if (k % 2) != 0:
        v = -v
        z = -z
    u = s / RADIUS_MM
    return mobius_point(u, v, z)


def clip_segment_to_v_band(
    p0: np.ndarray, p1: np.ndarray, v_min: float, v_max: float
) -> Tuple[np.ndarray, np.ndarray] | None:
    """
    Clip a 2D segment against the strip-width band in parameter space (v-axis only).
    """
    a = np.asarray(p0, dtype=np.float64)
    b = np.asarray(p1, dtype=np.float64)
    dv = b[1] - a[1]

    t0, t1 = 0.0, 1.0
    if abs(dv) < EPS:
        if a[1] < v_min - EPS or a[1] > v_max + EPS:
            return None
        return a, b

    lo = (v_min - a[1]) / dv
    hi = (v_max - a[1]) / dv
    t_enter = min(lo, hi)
    t_exit = max(lo, hi)
    t0 = max(t0, t_enter)
    t1 = min(t1, t_exit)
    if t1 < t0 - EPS:
        return None

    c0 = a + (b - a) * t0
    c1 = a + (b - a) * t1
    if np.linalg.norm(c1 - c0) < WIRE_MIN_SEGMENT_MM:
        return None
    return c0, c1


def quantized_point_key_3d(p: np.ndarray, tol: float = 1e-4) -> Tuple[int, int, int]:
    q = np.round(np.asarray(p, dtype=np.float64) / tol).astype(np.int64)
    return int(q[0]), int(q[1]), int(q[2])


def quantized_point_key_2d(p: np.ndarray, tol: float = 1e-4) -> Tuple[int, int]:
    q = np.round(np.asarray(p, dtype=np.float64) / tol).astype(np.int64)
    return int(q[0]), int(q[1])


def segment_frame_transform(p0: np.ndarray, p1: np.ndarray) -> Tuple[np.ndarray, float]:
    """
    Return a transform that maps the +Z axis segment [0, length] to [p0, p1].
    """
    p0 = np.asarray(p0, dtype=np.float64)
    p1 = np.asarray(p1, dtype=np.float64)
    vec = p1 - p0
    length = float(np.linalg.norm(vec))
    if length < EPS:
        raise ValueError("Segment length is too small")

    z_dir = vec / length
    ref = np.array([1.0, 0.0, 0.0], dtype=np.float64)
    if abs(float(np.dot(z_dir, ref))) > 0.9:
        ref = np.array([0.0, 1.0, 0.0], dtype=np.float64)
    x_dir = normalize(np.cross(ref, z_dir))
    y_dir = normalize(np.cross(z_dir, x_dir))

    tf = np.eye(4, dtype=np.float64)
    tf[:3, 0] = x_dir
    tf[:3, 1] = y_dir
    tf[:3, 2] = z_dir
    tf[:3, 3] = p0
    return tf, length


def make_segment_strut_mesh(p0: np.ndarray, p1: np.ndarray, radius: float) -> trimesh.Trimesh:
    """
    Build a round strut along a segment, using capsules when enabled.
    """
    p0 = np.asarray(p0, dtype=np.float64)
    p1 = np.asarray(p1, dtype=np.float64)
    seg_len = float(np.linalg.norm(p1 - p0))
    if seg_len < WIRE_MIN_SEGMENT_MM:
        raise ValueError("Segment too short for strut mesh")

    if WIRE_USE_CAPSULE_STRUTS:
        tf, height = segment_frame_transform(p0, p1)
        lat_lon = np.asarray(WIRE_CAPSULE_LAT_LONG_COUNT, dtype=np.int64)
        if lat_lon.shape != (2,):
            raise ValueError("WIRE_CAPSULE_LAT_LONG_COUNT must be a 2-tuple")
        return trimesh.creation.capsule(
            height=height,
            radius=float(radius),
            count=lat_lon,
            transform=tf,
        )

    return trimesh.creation.cylinder(
        radius=float(radius),
        segment=np.vstack([p0, p1]),
        sections=int(WIRE_STRUT_SECTIONS),
    )


def canonical_mobius_param_point(s: float, v: float) -> np.ndarray:
    """
    Canonicalize (s, v) under the Möbius seam identification to s in [0, L).
    """
    circumference = 2.0 * math.pi * RADIUS_MM
    k = math.floor(s / circumference)
    s_wrapped = s - k * circumference
    v_wrapped = -v if (k % 2) else v
    # Guard against rare float roundoff to exactly L
    if s_wrapped >= circumference - 1e-9:
        s_wrapped = 0.0
        v_wrapped = -v_wrapped
    if s_wrapped < 0.0:
        s_wrapped += circumference
        v_wrapped = -v_wrapped
    return np.array([s_wrapped, v_wrapped], dtype=np.float64)


def build_honeycomb_edge_segments_param(
    layout: HoneycombLayout,
) -> Tuple[List[Tuple[np.ndarray, np.ndarray]], dict[Tuple[int, int], np.ndarray], int]:
    """
    Build unique honeycomb strut segments in strip parameter space on the Möbius mid-surface.

    Returns canonicalized (s, v) endpoints, a node map, and duplicate count.
    """
    v_min = -0.5 * STRIP_WIDTH_MM
    v_max = +0.5 * STRIP_WIDTH_MM
    poly = hex_polygon_flat_top(layout.pitch_side)

    seen_edges = set()
    node_map: dict[Tuple[int, int], np.ndarray] = {}
    segments_2d: List[Tuple[np.ndarray, np.ndarray]] = []
    duplicate_count = 0

    for center in layout.centers:
        verts = poly + center[None, :]
        for i in range(len(verts)):
            p0_2d = verts[i]
            p1_2d = verts[(i + 1) % len(verts)]
            clipped = clip_segment_to_v_band(p0_2d, p1_2d, v_min, v_max)
            if clipped is None:
                continue
            c0, c1 = clipped
            if np.linalg.norm(c1 - c0) < WIRE_MIN_SEGMENT_MM:
                continue

            c0c = canonical_mobius_param_point(float(c0[0]), float(c0[1]))
            c1c = canonical_mobius_param_point(float(c1[0]), float(c1[1]))
            k0 = quantized_point_key_2d(c0c)
            k1 = quantized_point_key_2d(c1c)
            edge_key = (k0, k1) if k0 <= k1 else (k1, k0)

            if edge_key in seen_edges:
                duplicate_count += 1
                continue
            seen_edges.add(edge_key)

            node_map.setdefault(k0, c0c)
            node_map.setdefault(k1, c1c)
            segments_2d.append((c0c, c1c))

    return segments_2d, node_map, duplicate_count


def build_honeycomb_edge_segments_3d(
    layout: HoneycombLayout,
    *,
    z_offset: float = 0.0,
) -> Tuple[List[Tuple[np.ndarray, np.ndarray]], int]:
    """
    Build unique honeycomb strut centerline segments directly on the Möbius surface.

    Segments are generated in strip parameter space, clipped to the width boundaries,
    then mapped to 3D with Möbius wrap semantics.
    """
    segments_2d, _nodes_2d, duplicate_count = build_honeycomb_edge_segments_param(layout)
    segments_3d: List[Tuple[np.ndarray, np.ndarray]] = []
    for c0, c1 in segments_2d:
        p0 = mobius_point_wrapped_s(float(c0[0]), float(c0[1]), z_offset)
        p1 = mobius_point_wrapped_s(float(c1[0]), float(c1[1]), z_offset)
        if np.linalg.norm(p1 - p0) < WIRE_MIN_SEGMENT_MM:
            continue
        segments_3d.append((p0, p1))
    return segments_3d, duplicate_count


def build_wire_honeycomb_lattice_mesh(layout: HoneycombLayout) -> trimesh.Trimesh:
    if WIRE_STRUT_DIAMETER_MM <= 0:
        raise ValueError("WIRE_STRUT_DIAMETER_MM must be > 0")
    surface_radius = 0.5 * WIRE_STRUT_DIAMETER_MM
    connector_radius = surface_radius * max(0.05, float(WIRE_CONNECTOR_STRUT_DIAMETER_SCALE))
    connector_radius_2 = connector_radius * max(0.05, float(WIRE_SECOND_DIAGONAL_DIAMETER_SCALE))

    print("Building honeycomb wireframe graph on Möbius surface...")
    segments_2d, node_map_2d, duplicates = build_honeycomb_edge_segments_param(layout)
    print(f"  unique surface segs: {len(segments_2d)} (deduped {duplicates})")
    print(f"  unique nodes:        {len(node_map_2d)}")

    if not segments_2d:
        raise RuntimeError("No lattice segments were generated")

    strut_specs: List[Tuple[np.ndarray, np.ndarray, float]] = []
    node_positions_for_spheres: List[np.ndarray] = []
    layer_nodes_3d_for_plaque: List[dict[Tuple[int, int], np.ndarray]] | None = None

    degree_map: dict[Tuple[int, int], int] = {}
    edge_keys_2d: List[Tuple[Tuple[int, int], Tuple[int, int]]] = []
    for c0, c1 in segments_2d:
        k0 = quantized_point_key_2d(c0)
        k1 = quantized_point_key_2d(c1)
        degree_map[k0] = degree_map.get(k0, 0) + 1
        degree_map[k1] = degree_map.get(k1, 0) + 1
        edge_keys_2d.append((k0, k1))

    if WIRE_DUPLICATE_SURFACES_AND_CONNECT:
        layer_count = max(2, int(WIRE_STACK_LAYER_COUNT))
        z_layers = np.linspace(
            -0.5 * STRIP_THICKNESS_MM,
            +0.5 * STRIP_THICKNESS_MM,
            layer_count,
            dtype=np.float64,
        )
        print(
            f"  building {layer_count} honeycomb layers through thickness "
            f"(z from {z_layers[0]:.3f} to {z_layers[-1]:.3f} mm)..."
        )

        layer_nodes_3d: List[dict[Tuple[int, int], np.ndarray]] = []
        for z in z_layers:
            layer_nodes_3d.append(
                {k: mobius_point_wrapped_s(float(c[0]), float(c[1]), float(z)) for k, c in node_map_2d.items()}
            )
        layer_nodes_3d_for_plaque = layer_nodes_3d

        # Honeycomb graph on every layer (this makes the connector region itself honeycomb-like)
        surface_layer_seg_count = 0
        for li in range(layer_count):
            nodes = layer_nodes_3d[li]
            for c0, c1 in segments_2d:
                k0 = quantized_point_key_2d(c0)
                k1 = quantized_point_key_2d(c1)
                p0 = nodes[k0]
                p1 = nodes[k1]
                if np.linalg.norm(p1 - p0) >= WIRE_MIN_SEGMENT_MM:
                    strut_specs.append((p0, p1, surface_radius))
                    surface_layer_seg_count += 1
        print(f"  layer honeycomb segs:{surface_layer_seg_count}")

        diag_count = 0
        diag2_count = 0
        vert_count = 0
        for li in range(layer_count - 1):
            lower = layer_nodes_3d[li]
            upper = layer_nodes_3d[li + 1]

            if WIRE_INTERLACED_DIAGONAL_CONNECTORS:
                for k0, k1 in edge_keys_2d:
                    # Alternate parity by edge and layer index for a woven connector pattern.
                    parity = (k0[0] + k0[1] + k1[0] + k1[1] + li) & 1
                    if parity == 0:
                        pa1 = upper[k0]
                        pb1 = lower[k1]
                        pa2 = upper[k1]
                        pb2 = lower[k0]
                    else:
                        pa1 = upper[k1]
                        pb1 = lower[k0]
                        pa2 = upper[k0]
                        pb2 = lower[k1]

                    if np.linalg.norm(pb1 - pa1) >= WIRE_MIN_SEGMENT_MM:
                        strut_specs.append((pa1, pb1, connector_radius))
                        diag_count += 1

                    if WIRE_SECOND_DIAGONAL_FAMILY and np.linalg.norm(pb2 - pa2) >= WIRE_MIN_SEGMENT_MM:
                        strut_specs.append((pa2, pb2, connector_radius_2))
                        diag2_count += 1

            if WIRE_ADD_BOUNDARY_VERTICAL_CONNECTORS:
                for k, deg in degree_map.items():
                    if deg >= 3:
                        continue
                    p_lo = lower[k]
                    p_hi = upper[k]
                    if np.linalg.norm(p_hi - p_lo) < WIRE_MIN_SEGMENT_MM:
                        continue
                    strut_specs.append((p_lo, p_hi, connector_radius))
                    vert_count += 1

        print(f"  diagonal connectors: {diag_count}")
        if WIRE_SECOND_DIAGONAL_FAMILY:
            print(f"  secondary diagonals: {diag2_count}")
        print(f"  boundary posts:      {vert_count}")

        if WIRE_ADD_NODE_SPHERES:
            for nodes in layer_nodes_3d:
                node_positions_for_spheres.extend(nodes.values())
    else:
        print("  single-surface wireframe lattice (no duplicated layer/connectors)...")
        for c0, c1 in segments_2d:
            p0 = mobius_point_wrapped_s(float(c0[0]), float(c0[1]), 0.0)
            p1 = mobius_point_wrapped_s(float(c1[0]), float(c1[1]), 0.0)
            if np.linalg.norm(p1 - p0) >= WIRE_MIN_SEGMENT_MM:
                strut_specs.append((p0, p1, surface_radius))
        if WIRE_ADD_NODE_SPHERES:
            node_positions_for_spheres.extend(
                mobius_point_wrapped_s(float(c[0]), float(c[1]), 0.0) for c in node_map_2d.values()
            )

    strut_meshes: List[trimesh.Trimesh] = []
    for p0, p1, radius in strut_specs:
        strut_meshes.append(make_segment_strut_mesh(p0, p1, radius))

    if WIRE_ADD_NODE_SPHERES:
        print("  adding node spheres...")
        node_keys = {}
        for p in node_positions_for_spheres:
            node_keys.setdefault(quantized_point_key_3d(p), p)
        base_sphere = trimesh.creation.icosphere(
            subdivisions=int(WIRE_NODE_SPHERE_SUBDIVISIONS),
            radius=surface_radius,
        )
        for p in node_keys.values():
            sph = base_sphere.copy()
            sph.apply_translation(p)
            strut_meshes.append(sph)
        print(f"  node spheres:        {len(node_keys)}")

    if ADD_ENDGAME_NOD_PLAQUE:
        try:
            u_attach = math.radians(PLAQUE_ATTACH_U_DEG)
            target_point, s_dir, w_dir, n_dir = local_surface_frame(
                u_attach, 0.46 * STRIP_WIDTH_MM, z=0.0
            )
            anchor = target_point
            if layer_nodes_3d_for_plaque:
                mid_nodes = layer_nodes_3d_for_plaque[len(layer_nodes_3d_for_plaque) // 2]
                pts = np.asarray(list(mid_nodes.values()), dtype=np.float64)
                if len(pts) > 0:
                    d2 = np.sum((pts - target_point[None, :]) ** 2, axis=1)
                    anchor = pts[int(np.argmin(d2))]
            plaque_meshes = build_numeric_plaque_meshes(
                anchor,
                x_axis=s_dir,
                y_axis=-n_dir,  # use a right-handed plaque frame so text is not mirrored
                out_axis=w_dir,
                stem_radius=0.5 * PLAQUE_STEM_DIAMETER_MM,
                text=PLAQUE_TEXT,
            )
            strut_meshes.extend(plaque_meshes)
            print(f"  endgame nod plaque:  {PLAQUE_TEXT} ({len(plaque_meshes)} parts)")
        except Exception as exc:
            print(f"  plaque skipped:      {exc}")

    print("Converting wireframe parts to manifold3d...")
    parts_m = [manifold_from_trimesh(m) for m in strut_meshes]

    print("Unioning wireframe lattice...")
    lattice_m = union_many(parts_m)

    print("Converting wireframe lattice back to trimesh...")
    mesh = trimesh_from_manifold(lattice_m)
    return mesh


def local_surface_frame(u: float, v: float, z: float = 0.0) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """
    Return (point, s_dir, w_dir, n_dir) on the strip mid-surface (z = 0).
    s_dir is tangent-ish along loop, w_dir across strip width, n_dir through thickness.
    """
    cu, su = math.cos(u), math.sin(u)
    half = 0.5 * u
    ch, sh = math.cos(half), math.sin(half)

    radial = np.array([cu, su, 0.0], dtype=np.float64)
    tangent = np.array([-su, cu, 0.0], dtype=np.float64)
    z_axis = np.array([0.0, 0.0, 1.0], dtype=np.float64)

    center = RADIUS_MM * radial
    w_dir = ch * radial + sh * z_axis
    w_dir = normalize(w_dir)
    t_dir = -sh * radial + ch * z_axis
    t_dir = normalize(t_dir)
    point = center + v * w_dir + z * t_dir

    # d/du of w_dir
    dw_du = (ch * tangent) + (-0.5 * sh * radial) + (0.5 * ch * z_axis)
    dt_du = (-0.5 * ch) * radial + (-sh) * tangent + (-0.5 * sh) * z_axis
    dpos_du = RADIUS_MM * tangent + v * dw_du + z * dt_du

    s_dir = normalize(dpos_du)
    n_dir = normalize(np.cross(s_dir, w_dir))
    # Re-orthogonalize w_dir to protect against accumulated numeric skew.
    w_dir = normalize(np.cross(n_dir, s_dir))
    return point, s_dir, w_dir, n_dir


def oriented_box_mesh(
    center: np.ndarray,
    x_axis: np.ndarray,
    y_axis: np.ndarray,
    z_axis: np.ndarray,
    size_x: float,
    size_y: float,
    size_z: float,
) -> trimesh.Trimesh:
    box = trimesh.creation.box(extents=[float(size_x), float(size_y), float(size_z)])
    tf = np.eye(4, dtype=np.float64)
    tf[:3, 0] = normalize(np.asarray(x_axis, dtype=np.float64))
    tf[:3, 1] = normalize(np.asarray(y_axis, dtype=np.float64))
    tf[:3, 2] = normalize(np.asarray(z_axis, dtype=np.float64))
    tf[:3, 3] = np.asarray(center, dtype=np.float64)
    box.apply_transform(tf)
    return box


def bitmap_glyph_5x7(ch: str) -> List[str]:
    """5x7 bitmap glyph rows for readable plaque text."""
    font = {
        " ": ["00000"] * 7,
        "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
        "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
        "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
        "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
        "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
        "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
        "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
        "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
        "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
        "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
        "A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
        "B": ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
        "D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
        "E": ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
        "G": ["01110", "10001", "10000", "10111", "10001", "10001", "01110"],
        "I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
        "L": ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
        "M": ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
        "N": ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
        "O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
        "P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
        "R": ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
        "S": ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
        "T": ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
        "U": ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
        "V": ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
        "Y": ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
    }
    return font.get(ch.upper(), font[" "])


def build_numeric_plaque_meshes(
    anchor_point: np.ndarray,
    x_axis: np.ndarray,
    y_axis: np.ndarray,
    out_axis: np.ndarray,
    stem_radius: float,
    text: str = PLAQUE_TEXT,
) -> List[trimesh.Trimesh]:
    """
    Build a small rectangular plaque with engraved text and an attachment stem.
    Plaque plane lies in (x_axis, y_axis); thickness extrudes along out_axis.
    """
    raw_text = str(text).strip() or "Mobius Strip"
    if "\n" in raw_text:
        lines = [line.strip().upper() for line in raw_text.splitlines() if line.strip()]
    else:
        words = [w for w in raw_text.split(" ") if w]
        if len(words) >= 2:
            # Prefer a two-line wrap for readability on a small plaque.
            lines = [" ".join(words[:-1]).upper(), words[-1].upper()]
        else:
            lines = [raw_text.upper()]
    lines = [line[:16] for line in lines[:3]]
    if not lines:
        lines = ["MOBIUS", "STRIP"]

    x_axis = normalize(np.asarray(x_axis, dtype=np.float64))
    y_axis = normalize(np.asarray(y_axis, dtype=np.float64))
    out_axis = normalize(np.asarray(out_axis, dtype=np.float64))
    anchor_point = np.asarray(anchor_point, dtype=np.float64)

    back_face_center = anchor_point + out_axis * PLAQUE_OFFSET_FROM_STRIP_MM
    plaque_center = back_face_center + out_axis * (0.5 * PLAQUE_THICKNESS_MM)

    plaque_mesh = oriented_box_mesh(
        plaque_center,
        x_axis,
        y_axis,
        out_axis,
        PLAQUE_WIDTH_MM,
        PLAQUE_HEIGHT_MM,
        PLAQUE_THICKNESS_MM,
    )
    meshes: List[trimesh.Trimesh] = [plaque_mesh]

    # Stem to attach the plaque to the lattice.
    stem_end = back_face_center
    if np.linalg.norm(stem_end - anchor_point) >= WIRE_MIN_SEGMENT_MM:
        meshes.append(make_segment_strut_mesh(anchor_point, stem_end, stem_radius))

    # Engraved 5x7 bitmap text for clearer reading on small printed plaques.
    text_w = PLAQUE_WIDTH_MM - 2.0 * PLAQUE_MARGIN_MM
    text_h = min(PLAQUE_TEXT_HEIGHT_MM, PLAQUE_HEIGHT_MM - 2.0 * PLAQUE_MARGIN_MM)
    text_cutters: List[trimesh.Trimesh] = []
    if text_w > 2.0 and text_h > 2.0 and len(lines) > 0:
        glyph_w = 5
        glyph_h = 7
        char_gap_units = 1
        line_gap_units = 2
        units_x_per_line = [
            (len(line) * glyph_w + max(0, len(line) - 1) * char_gap_units) if len(line) else glyph_w
            for line in lines
        ]
        total_units_x = max(units_x_per_line)
        total_units_y = len(lines) * glyph_h + max(0, len(lines) - 1) * line_gap_units
        cell = min(text_w / max(1, total_units_x), text_h / max(1, total_units_y))
        if cell > 0.1:
            engrave_depth = min(max(0.2, PLAQUE_TEXT_EMBOSS_MM), PLAQUE_THICKNESS_MM - 0.2)
            engrave_center_z = 0.5 * PLAQUE_THICKNESS_MM - 0.5 * engrave_depth + 0.01
            run_h = min(cell * 0.96, max(0.15, PLAQUE_TEXT_STROKE_MM))
            run_expand_x = cell * 0.06
            total_render_h = total_units_y * cell
            y_top = +0.5 * total_render_h - 0.5 * cell

            for line_idx, line in enumerate(lines):
                units_x = units_x_per_line[line_idx]
                x_left = -0.5 * (units_x * cell)
                cursor_units = 0
                row_offset = line_idx * (glyph_h + line_gap_units)

                for ch in line:
                    glyph = bitmap_glyph_5x7(ch)
                    for row_idx, row in enumerate(glyph):
                        col = 0
                        while col < glyph_w:
                            if row[col] != "1":
                                col += 1
                                continue
                            start = col
                            while col < glyph_w and row[col] == "1":
                                col += 1
                            run_len = col - start
                            cx = x_left + (cursor_units + start + 0.5 * run_len) * cell
                            cy = y_top - (row_offset + row_idx) * cell
                            center = (
                                plaque_center
                                + cx * x_axis
                                + cy * y_axis
                                + engrave_center_z * out_axis
                            )
                            text_cutters.append(
                                oriented_box_mesh(
                                    center,
                                    x_axis,
                                    y_axis,
                                    out_axis,
                                    max(0.15, run_len * cell + run_expand_x),
                                    max(0.15, run_h),
                                    engrave_depth + 0.05,
                                )
                            )
                    cursor_units += glyph_w + char_gap_units

    if text_cutters:
        try:
            plaque_m = manifold_from_trimesh(plaque_mesh)
            text_m = union_many([manifold_from_trimesh(m) for m in text_cutters])
            engraved_m = manifold_difference(plaque_m, text_m)
            meshes[0] = trimesh_from_manifold(engraved_m)
        except Exception as exc:
            print(f"  plaque engraving fallback (unengraved): {exc}")

    return meshes


def prism_mesh_from_polygon(
    center: np.ndarray,
    x_axis: np.ndarray,
    y_axis: np.ndarray,
    z_axis: np.ndarray,
    polygon_xy: np.ndarray,
    depth: float,
) -> trimesh.Trimesh:
    n = polygon_xy.shape[0]
    if n < 3:
        raise ValueError("Polygon must have at least 3 vertices")

    half = 0.5 * depth
    bottom = []
    top = []
    for x, y in polygon_xy:
        offset = x * x_axis + y * y_axis
        bottom.append(center + offset - half * z_axis)
        top.append(center + offset + half * z_axis)

    vertices = np.vstack([np.asarray(bottom), np.asarray(top)])
    faces: List[Tuple[int, int, int]] = []

    # Bottom face (outward is -z): reverse winding of the CCW polygon
    for i in range(1, n - 1):
        faces.append((0, i + 1, i))

    # Top face (outward is +z)
    top0 = n
    for i in range(1, n - 1):
        faces.append((top0, top0 + i, top0 + i + 1))

    # Side faces
    for i in range(n):
        j = (i + 1) % n
        b0, b1 = i, j
        t0, t1 = n + i, n + j
        faces.append((b0, b1, t1))
        faces.append((b0, t1, t0))

    mesh = trimesh.Trimesh(
        vertices=vertices,
        faces=np.asarray(faces, dtype=np.int64),
        process=False,
        validate=False,
    )
    mesh.fix_normals()
    if mesh.volume < 0:
        mesh.invert()
    return mesh


def shifted_honeycomb_centers(
    layout: HoneycombLayout,
    s_shift: float = 0.0,
    v_shift: float = 0.0,
    y_limit_override: float | None = None,
) -> np.ndarray:
    circumference = 2.0 * math.pi * RADIUS_MM
    c = np.array(layout.centers, copy=True)
    c[:, 0] = np.mod(c[:, 0] + s_shift, circumference)
    c[:, 1] = c[:, 1] + v_shift
    y_limit = layout.y_limit if y_limit_override is None else float(y_limit_override)
    keep = np.abs(c[:, 1]) <= (y_limit + EPS)
    return c[keep]


def honeycomb_y_limit_for_width(width: float, hole_apothem: float, edge_margin: float) -> float:
    return (0.5 * width) - hole_apothem - max(edge_margin, 0.5 * HEX_WALL_THICKNESS_MM)


def build_honeycomb_cutters(
    layout: HoneycombLayout,
    *,
    centers: np.ndarray | None = None,
    hole_side: float | None = None,
    cutter_depth: float | None = None,
    z_center: float = 0.0,
) -> List[trimesh.Trimesh]:
    cutters: List[trimesh.Trimesh] = []
    poly = hex_polygon_flat_top(layout.hole_side if hole_side is None else hole_side)
    cutter_depth = (
        STRIP_THICKNESS_MM + 2.0 * CUTTER_DEPTH_MARGIN_MM
        if cutter_depth is None
        else float(cutter_depth)
    )
    centers_use = layout.centers if centers is None else centers

    for s, v in centers_use:
        u = s / RADIUS_MM
        point, s_dir, w_dir, n_dir = local_surface_frame(u, v, z=z_center)
        cutters.append(prism_mesh_from_polygon(point, s_dir, w_dir, n_dir, poly, cutter_depth))

    return cutters


def build_embedded_honeycomb_mesh(layout: HoneycombLayout) -> trimesh.Trimesh:
    if SHELL_FACE_SKIN_MM <= 0 or SHELL_SIDE_SKIN_MM <= 0:
        raise ValueError("Shell skin thicknesses must be > 0")

    cavity_width = STRIP_WIDTH_MM - 2.0 * SHELL_SIDE_SKIN_MM
    cavity_thickness = STRIP_THICKNESS_MM - 2.0 * SHELL_FACE_SKIN_MM
    if cavity_width <= 0 or cavity_thickness <= 0:
        raise ValueError("Shell skin settings consume the entire strip; reduce shell skins")

    print("  building outer shell...")
    outer = build_mobius_solid_mesh(width=STRIP_WIDTH_MM, thickness=STRIP_THICKNESS_MM)
    cavity = build_mobius_solid_mesh(width=cavity_width, thickness=cavity_thickness)
    shell_m = manifold_difference(manifold_from_trimesh(outer), manifold_from_trimesh(cavity))

    shell_open_y_limit = honeycomb_y_limit_for_width(
        cavity_width, layout.hole_apothem * FACE_WINDOW_HOLE_SCALE, EDGE_MARGIN_MM
    )
    if shell_open_y_limit <= 0:
        raise ValueError("Face-window honeycomb does not fit within shell opening width")

    top_centers = shifted_honeycomb_centers(layout, y_limit_override=shell_open_y_limit)
    bottom_centers = shifted_honeycomb_centers(
        layout,
        s_shift=FACE_WINDOW_BOTTOM_S_SHIFT * layout.dx,
        y_limit_override=shell_open_y_limit,
    )

    z_face = 0.5 * STRIP_THICKNESS_MM - 0.5 * SHELL_FACE_SKIN_MM
    face_cutter_depth = SHELL_FACE_SKIN_MM + 2.0 * CUTTER_DEPTH_MARGIN_MM
    print(
        "  cutting honeycomb windows in shell faces..."
        f" top={len(top_centers)} bottom={len(bottom_centers)} z_face={z_face:.3f} mm"
    )
    top_cutters = build_honeycomb_cutters(
        layout,
        centers=top_centers,
        hole_side=layout.hole_side * FACE_WINDOW_HOLE_SCALE,
        cutter_depth=face_cutter_depth,
        z_center=+z_face,
    )
    bottom_cutters = build_honeycomb_cutters(
        layout,
        centers=bottom_centers,
        hole_side=layout.hole_side * FACE_WINDOW_HOLE_SCALE,
        cutter_depth=face_cutter_depth,
        z_center=-z_face,
    )
    face_cutters_m = union_many([manifold_from_trimesh(c) for c in (top_cutters + bottom_cutters)])
    shell_open_m = manifold_difference(shell_m, face_cutters_m)

    core_width = cavity_width + CORE_TO_SHELL_OVERLAP_MM
    if CORE_LAYER_COUNT < 1:
        raise ValueError("CORE_LAYER_COUNT must be >= 1")
    if CORE_LAYER_THICKNESS_MM <= 0:
        raise ValueError("CORE_LAYER_THICKNESS_MM must be > 0")
    if CORE_LAYER_THICKNESS_MM > cavity_thickness + CORE_TO_SHELL_OVERLAP_MM + EPS:
        raise ValueError("CORE_LAYER_THICKNESS_MM is larger than the shell cavity thickness")

    core_layer_span = cavity_thickness + CORE_TO_SHELL_OVERLAP_MM
    core_layer_centers_z = np.linspace(
        -(core_layer_span - CORE_LAYER_THICKNESS_MM) * 0.5,
        +(core_layer_span - CORE_LAYER_THICKNESS_MM) * 0.5,
        CORE_LAYER_COUNT,
        dtype=np.float64,
    )
    layer_manifolds = []
    print(
        "  building internal honeycomb core layers..."
        f" count={CORE_LAYER_COUNT} layer_t={CORE_LAYER_THICKNESS_MM:.3f} mm core_w={core_width:.3f} mm"
    )
    for idx, zc in enumerate(core_layer_centers_z):
        is_center = (CORE_LAYER_COUNT % 2 == 1) and (idx == CORE_LAYER_COUNT // 2)
        hole_scale = CORE_CENTER_LAYER_HOLE_SCALE if is_center else CORE_HOLE_SCALE
        hole_side = layout.hole_side * hole_scale
        hole_apothem = 0.5 * SQRT3 * hole_side
        core_y_limit = honeycomb_y_limit_for_width(core_width, hole_apothem, EDGE_MARGIN_MM)
        if core_y_limit <= 0:
            raise ValueError("Core honeycomb does not fit; reduce core hole sizes or shell skins")

        s_shift_cols = CORE_S_SHIFT + (idx * 0.5)
        centers = shifted_honeycomb_centers(
            layout,
            s_shift=s_shift_cols * layout.dx,
            y_limit_override=core_y_limit,
        )
        print(
            f"    core layer {idx+1}/{CORE_LAYER_COUNT}: z={zc:.3f} mm "
            f"holes={len(centers)} hole_scale={hole_scale:.3f} s_shift_cols={s_shift_cols:.2f}"
        )

        core_layer = build_mobius_solid_mesh(
            width=core_width,
            thickness=CORE_LAYER_THICKNESS_MM,
            z_center=float(zc),
        )
        core_cutters = build_honeycomb_cutters(
            layout,
            centers=centers,
            hole_side=hole_side,
            cutter_depth=CORE_LAYER_THICKNESS_MM + 2.0 * CUTTER_DEPTH_MARGIN_MM,
            z_center=float(zc),
        )
        core_layer_m = manifold_difference(
            manifold_from_trimesh(core_layer),
            union_many([manifold_from_trimesh(c) for c in core_cutters]),
        )
        layer_manifolds.append(core_layer_m)

    core_m = union_many(layer_manifolds)

    print("  unioning shell + core lattice...")
    final_m = union_many([shell_open_m, core_m])
    return trimesh_from_manifold(final_m)


def verify_mesh_for_export(mesh: trimesh.Trimesh) -> None:
    mesh.remove_unreferenced_vertices()
    mesh.fix_normals()
    if mesh.volume < 0:
        mesh.invert()

    if not mesh.is_watertight:
        raise RuntimeError("Final mesh is not watertight")

    if hasattr(mesh, "is_winding_consistent") and not mesh.is_winding_consistent:
        raise RuntimeError("Final mesh winding is inconsistent")

    if len(mesh.faces) == 0 or len(mesh.vertices) == 0:
        raise RuntimeError("Final mesh is empty")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generate a Möbius strip with honeycomb lattice geometry")
    parser.add_argument("--preview", action="store_true", default=PREVIEW_DEFAULT, help="Open a trimesh preview of the final mesh")
    parser.add_argument("--no-export", action="store_true", help="Build and verify, but do not export STL")
    parser.add_argument("--out", default=OUTPUT_FILENAME, help=f"Output STL filename (default: {OUTPUT_FILENAME})")
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    layout = fit_honeycomb_layout()
    print("Honeycomb layout (seamless fit around loop):")
    print(f"  columns around loop: {layout.n_cols}")
    print(f"  holes total:         {layout.n_holes}")
    print(f"  fitted dx/dy:        {layout.dx:.3f} / {layout.dy:.3f} mm")
    print(f"  actual wall:         {layout.actual_wall:.3f} mm (requested {HEX_WALL_THICKNESS_MM:.3f} mm)")
    if WIRE_HONEYCOMB_LATTICE_MODE:
        mode_name = "wireframe honeycomb lattice (struts form the Möbius strip)"
    elif EMBEDDED_HONEYCOMB_MODE:
        mode_name = "hollow shell + embedded honeycomb core"
    else:
        mode_name = "through-hole honeycomb sheet"
    print(f"  mode:                {mode_name}")

    if WIRE_HONEYCOMB_LATTICE_MODE:
        print("Building wireframe honeycomb lattice Möbius...")
        final_mesh = build_wire_honeycomb_lattice_mesh(layout)
    elif EMBEDDED_HONEYCOMB_MODE:
        print("Building hollow shell with embedded honeycomb core...")
        final_mesh = build_embedded_honeycomb_mesh(layout)
    else:
        print("Building thick Möbius strip base mesh...")
        base = build_mobius_solid_mesh()
        print(f"  base vertices={len(base.vertices)} faces={len(base.faces)} watertight={base.is_watertight}")
        if not base.is_watertight:
            raise RuntimeError("Base Möbius solid mesh is not watertight before boolean operations")

        print("Building cutter prisms...")
        cutter_meshes = build_honeycomb_cutters(layout)
        print(f"  cutters: {len(cutter_meshes)}")

        print("Converting meshes to manifold3d...")
        base_m = manifold_from_trimesh(base)
        cutter_m_list = [manifold_from_trimesh(cm) for cm in cutter_meshes]

        print("Unioning cutters...")
        cutters_union = union_many(cutter_m_list)

        print("Applying boolean difference (base - honeycomb cutters)...")
        final_m = manifold_difference(base_m, cutters_union)

        print("Converting result back to trimesh...")
        final_mesh = trimesh_from_manifold(final_m)

    print("Verifying final mesh...")
    verify_mesh_for_export(final_mesh)
    print(
        f"  final vertices={len(final_mesh.vertices)} faces={len(final_mesh.faces)} "
        f"watertight={final_mesh.is_watertight} volume={final_mesh.volume:.2f} mm^3"
    )

    if args.preview:
        print("Opening preview...")
        try:
            trimesh.Scene(final_mesh).show()
        except Exception as exc:  # pragma: no cover
            print(f"Preview failed: {exc}")

    if not args.no_export:
        print(f"Exporting STL -> {args.out}")
        final_mesh.export(args.out)
        print("Done.")
    else:
        print("Skipping export (--no-export).")


if __name__ == "__main__":
    main()
