"""
Reef Tank Parametric Media/Filter Stack System
FreeCAD 1.0 Python Macro  -  v2

HOW TO USE:
  1. Open FreeCAD 1.0
  2. Macro > Macros > Create  (or open this file directly)
  3. Edit parameters in the PARAMETERS section below
  4. Click Execute (F6)

All dimensions are in millimeters.

DESIGN NOTES (v2):
  * Connector is now a STEPPED design, not an external collar:
      - Male (top): an inset tube that rises from the inner wall, so it stays
        within the body diameter and is structurally welded to the wall.
      - Female (bottom): a flush ring whose outer face is level with the body
        wall, joined to both the wall and the floor.
    A module's male top inserts into the female bottom of the module above.
  * Wall pattern is one continuous honeycomb running the full height:
      - In the BOTTOM region it is cut ALL THE WAY THROUGH (drains water).
      - In the TOP region it is only a shallow recess / emboss (stays solid),
        leaving the honeycomb web standing proud, like the reference cup.
"""

import FreeCAD as App
import FreeCADGui as Gui
import Part
import math

# ==============================================================================
# PARAMETERS  -  edit these to drive the entire model
# ==============================================================================

# --- Filter sock hole (PRIMARY dimension for the round system) ---
# The holder REPLACES the filter sock and drops into the sump baffle hole that
# the sock used to sit in. A nominal "4 inch" sock uses a ~95 mm (3.75") hole;
# a full 4" (101.6 mm) hole lets socks fall through, so 95 mm is the real target.
SOCK_HOLE_DIAMETER    = 95.0   # the sump baffle hole the holder drops into

# --- Module (stackable basket; diameter DERIVED to maximize volume) ---
# MOD_DIAMETER is computed so the basket is as large as possible while still
# passing through the sock hole when the holder+stack is lowered in as a unit.
# To use a fixed size instead, just replace the formula with a number.
MODULE_HOLE_CLEARANCE = 2.0    # radial gap so the module clears the hole edge
MOD_DIAMETER        = SOCK_HOLE_DIAMETER - 2 * MODULE_HOLE_CLEARANCE   # derived
WALL_THICKNESS      = 2.5     # body wall thickness
TOP_HEIGHT          = 30.0    # solid (embossed) upper section. 0 = pattern is all through-cut
BOTTOM_HEIGHT       = 40.0    # open (through-cut) lower section. 0 = solid sides, floor still drains

# --- Twist-lock bayonet connector (hemispherical pins riding in an L-track) ---
CONNECTOR_HEIGHT    = 10.0    # insertion depth of the male plug / female socket
CONNECTOR_WALL      = 4.0     # female socket wall thickness (thick, holds the L-track)
CONNECTOR_CLEARANCE = 0.4     # radial fit gap between male plug and female bore
PIN_COUNT           = 3       # number of bayonet pins (2 or 3 recommended)
PIN_RADIUS          = 2.0     # hemispherical pin radius = how far it sticks out
PIN_TRACK_CLEARANCE = 0.35    # gap around the pin inside the slot/track
CONNECTOR_BACK_WALL = 1.4     # min solid wall left behind the track groove
LOCK_TWIST_DEG      = 25.0    # how far you twist to lock (degrees)
LOCK_CHANNEL_FLAT   = 12.0    # arc (mm) of the PARALLEL free-travel run before the
                             # pinch: longer = more twist before it grips/locks
MOUNT_CLEARANCE     = 0.0    # extra plug length on the holder/plate males: pushes the
                             # pin out by this much so a mounted module hangs with this
                             # gap below the plate. 0 pulls the module up snug, which
                             # firms up the assembly; raise it if your fit binds.

# --- Hole / grate pattern ---
# Options: "hexagon", "circle", "square", "pentagon"  (squares are axis-aligned with
# flat top/bottom edges, laid in a running-bond brick pattern with alternate rows
# offset half a cell)
HOLE_PATTERN        = "hexagon"
HOLE_SIZE           = 6.0     # opening size, flat-to-flat (mm)
HOLE_SPACING        = 1.6     # web thickness between openings (mm)
EMBOSS_DEPTH        = 1.0     # recess depth of the pattern in the solid TOP region (mm)

# --- Bottom grate (integrated honeycomb floor of each module's media chamber) ---
# The grate sits at the bottom of the media chamber, recessed above the female
# socket. The module below plugs UP into that socket. The grate is a honeycomb
# so it bridge-prints (short web-to-web spans) with no supports when the module
# is printed socket-down. Bigger holes / thicker webs print and flow better here.
GRATE_HOLE_SIZE     = 7.5     # grate opening size, flat-to-flat (mm)
GRATE_WEB           = 2.0     # grate web thickness (mm); bears the media weight
GRATE_THICKNESS     = 2.5     # vertical thickness of the grate (mm)

# --- Circular holder (the sock replacement that sits in SOCK_HOLE_DIAMETER) ---
# Lip rests on the baffle; rim centers in the hole; a downward connector on the
# underside accepts the top module, which hangs below into the sump. Open bore.
HOLDER_RIM_FIT_CLEARANCE    = 1.0    # radial slack so the rim drops into the hole
CIRC_HOLDER_LIP_WIDTH       = 4.0    # overhang to match a 4mm sock-holder ledge
CIRC_HOLDER_RIM_DEPTH       = 15.0   # how far the rim drops into the hole
CIRC_HOLDER_RIM_THICKNESS   = 2.5
CIRC_HOLDER_FLOOR_THICKNESS = 3.0    # lip thickness (matches the 3mm ledge)

# --- Rectangular holder (two pieces: frame + drop-in plate) ---
# The frame (lip + sides) and the plate (with the bores + downward males) print
# separately with NO supports. The plate drops into the frame and catches on a
# 45-deg bevel seat: frame prints lip-down (the bevel becomes a self-supporting
# overhang); plate prints flat. See assembly notes in the build summary.
#
# Stepped frame (top -> down): lip flange rests in the countersunk slot -> body
# descends to make floss room -> 45-deg bevel "catch" the plate seats on.
#   Lip perimeter (fits the countersunk slot) = WIDTH + 2*LIP_X  by  DEPTH + 2*LIP_Y
#   Descending body (drops through the slot)  = WIDTH x DEPTH
#   Floss room above the plate               = RIM_DEPTH - PLATE_THICKNESS
RECT_HOLDER_WIDTH           = 219.0   # descending body X  -> lip perimeter 249 (=219+2*15)
RECT_HOLDER_DEPTH           = 87.0    # descending body Y  -> lip perimeter 109 (=87+2*11)
RECT_HOLDER_LIP_X           = 15.0    # lip overhang on the short ends (rests on slot ledge)
RECT_HOLDER_LIP_Y           = 11.0    # lip overhang on the long sides
RECT_HOLDER_RIM_DEPTH       = 30.0    # descent below the lip (~25 mm floss room + 5 mm plate)
RECT_HOLDER_RIM_THICKNESS   = 2.5     # descending wall thickness
RECT_HOLDER_FLOOR_THICKNESS = 3.0     # lip flange thickness
RECT_PLATE_THICKNESS        = 5.0    # thickness of the drop-in plate (>= bevel)
RECT_PLATE_BEVEL            = 4.0    # 45-deg catch seat the plate rests on
RECT_PLATE_CLEARANCE        = 0.4    # gap so the plate drops into the frame freely
STACK_CLEARANCE             = 2.0    # gap between adjacent cans on the plate
RECT_CAN_SPREAD             = 8.0    # move each can this far outward (toward the short
                                     # ends) so there is room to twist it onto the plate
RECT_RING_GAP               = 1.5    # radial clearance milled through the frame seat so
                                     # the can's connector ring passes and the plate seats

# --- Floss support: flat perforated rectangle that lies in the frame on top of the
#     plate, giving the filter floss a supported surface (same pattern as the cups) ---
FLOSS_SUPPORT_THICKNESS  = 2.5    # sheet thickness
FLOSS_SUPPORT_BORDER     = 3.0    # solid border width around the perforated field

# --- Build control (defaults; the dialog overrides these at run time) ---
BUILD_MODULE             = True
BUILD_CIRCULAR_HOLDER    = False
BUILD_RECTANGULAR_FRAME  = False
BUILD_RECTANGULAR_PLATE  = False
BUILD_FLOSS_SUPPORT      = False
LAYOUT_SPACING           = 20.0

# Small positive overlap used to guarantee solid (manifold) welds between parts.
WELD = 0.15

# ==============================================================================
# GENERIC HELPERS
# ==============================================================================

def make_cylinder_shell(outer_r, inner_r, height, z=0.0):
    """Hollow tube."""
    outer = Part.makeCylinder(outer_r, height, App.Vector(0, 0, z))
    inner = Part.makeCylinder(inner_r, height, App.Vector(0, 0, z))
    return outer.cut(inner)


def pattern_name_to_sides(name):
    """Number of polygon sides; 0 means circle."""
    return {"square": 4, "pentagon": 5, "hexagon": 6, "circle": 0}.get(name.lower(), 6)


def make_polygon_wire_xy(n, circumradius, z=0.0):
    """Regular polygon wire in the XY plane (used by the flat bottom grate)."""
    start = 45.0 if n == 4 else 90.0          # axis-aligned square; pointy-top otherwise
    pts = []
    for i in range(n):
        a = math.radians(start + i * 360.0 / n)
        pts.append(App.Vector(circumradius * math.cos(a),
                              circumradius * math.sin(a), z))
    pts.append(pts[0])
    return Part.makePolygon(pts)


# ==============================================================================
# RADIAL CUTTER  -  a hole shape whose axis points toward the cylinder axis
# ==============================================================================

def make_radial_cutter(sides, flat2flat, r_in, r_out, z, angle_rad):
    """
    Build one hole-cutter solid for a curved wall.

    The shape cross-section lies in the (tangential, vertical) plane and the
    solid is extruded RADIALLY (along +X) from r_in to r_out, then lifted to
    height z and rotated about the Z axis to angle_rad.  This is the correct
    orientation for punching a shape through a cylindrical wall.
    """
    length = r_out - r_in
    if sides == 0:
        # circle of diameter flat2flat, lying in the Y-Z plane (normal = X)
        circ = Part.makeCircle(flat2flat / 2.0,
                               App.Vector(0, 0, 0), App.Vector(1, 0, 0))
        face = Part.Face(Part.Wire(circ))
        sol = face.extrude(App.Vector(length, 0, 0))
    else:
        a = flat2flat / 2.0                     # apothem
        R = a / math.cos(math.pi / sides)       # circumradius
        start = math.pi / 2.0                   # pointy-top (good for hexagons)
        if sides == 4:
            start = math.pi / 4.0               # axis-aligned square: flat top/bottom
        pts = []
        for i in range(sides):
            ang = start + i * 2.0 * math.pi / sides
            pts.append(App.Vector(0, R * math.cos(ang), R * math.sin(ang)))
        pts.append(pts[0])
        face = Part.Face(Part.makePolygon(pts))
        sol = face.extrude(App.Vector(length, 0, 0))

    sol.translate(App.Vector(r_in, 0, z))
    sol.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), math.degrees(angle_rad))
    return sol


# ==============================================================================
# PATTERNED WALL  -  continuous honeycomb; through-cut bottom, embossed top
# ==============================================================================

def make_patterned_wall(outer_r, inner_r, total_h, through_h,
                        pattern, hole_size, spacing, emboss_depth, z_base=0.0):
    """
    Solid tube with the repeating pattern applied over the whole height.
    Cells whose centre (relative) z < through_h are cut completely through
    (drain holes); cells above that are only recessed by emboss_depth.
    The tube is built starting at z_base.
    """
    shell = make_cylinder_shell(outer_r, inner_r, total_h, z_base)
    sides = pattern_name_to_sides(pattern)
    circ = 2.0 * math.pi * outer_r

    # Choose a column count that makes the pattern wrap seamlessly.
    target_pitch = hole_size + spacing
    n_cols = max(3, int(round(circ / target_pitch)))
    horiz_pitch = circ / n_cols                  # exact, seamless
    f2f = max(1.0, horiz_pitch - spacing)        # actual opening size

    if sides == 6:
        R = f2f / math.sqrt(3.0)
        vert_pitch = 1.5 * R + spacing
    elif sides == 0:
        vert_pitch = f2f + spacing
    else:
        vert_pitch = f2f + spacing

    # Solid bands: a thin one at the very base (so the wall joins the female
    # socket cleanly) and a small one at the top (where the male connector ties
    # in). The through-cut lattice otherwise runs all the way down.
    base_band = max(spacing, WALL_THICKNESS * 0.6)
    cutters = []
    z = base_band + f2f / 2.0
    z_stop = total_h - max(vert_pitch * 0.6, f2f / 2.0)   # solid rim at the top
    row = 0
    while z <= z_stop:
        offset = (horiz_pitch / 2.0) if (sides in (4, 6) and row % 2 == 1) else 0.0
        if z < through_h:
            r_in, r_out = inner_r - 1.0, outer_r + 1.0          # punch through
        else:
            r_in, r_out = outer_r - emboss_depth, outer_r + 1.0  # shallow recess
        for col in range(n_cols):
            ang = ((col * horiz_pitch + offset) / circ) * 2.0 * math.pi
            cutters.append(make_radial_cutter(sides, f2f, r_in, r_out,
                                              z + z_base, ang))
        z += vert_pitch
        row += 1

    if cutters:
        try:
            shell = shell.cut(Part.makeCompound(cutters))
        except Exception:
            for c in cutters:
                try:
                    shell = shell.cut(c)
                except Exception:
                    pass
    return shell


# ==============================================================================
# FLAT BOTTOM GRATE
# ==============================================================================

def make_disc_grate(outer_r, rim_inset, thickness, z, pattern, hole_size, spacing):
    """
    Flat disc with a repeating through-pattern, used for the basket floor.
    A solid margin of width rim_inset is kept around the rim so the floor
    stays joined to the wall.
    """
    disc = Part.makeCylinder(outer_r, thickness, App.Vector(0, 0, z))
    sides = pattern_name_to_sides(pattern)
    cell = hole_size + spacing
    n = max(1, int((outer_r * 2.0) / cell))

    cuts = []
    for row in range(-n, n + 1):
        for col in range(-n, n + 1):
            if sides == 6:
                ox = col * cell + (cell / 2.0 if row % 2 else 0.0)
                oy = row * cell * math.sqrt(3.0) / 2.0
            elif sides == 4:
                ox = col * cell + (cell / 2.0 if row % 2 else 0.0)
                oy = row * cell
            else:
                ox = col * cell
                oy = row * cell
            dist = math.sqrt(ox * ox + oy * oy)
            if dist + hole_size / 2.0 > outer_r - max(spacing, rim_inset):
                continue
            if dist < spacing:
                continue

            if sides == 0:
                w = Part.Wire(Part.makeCircle(hole_size / 2.0,
                                              App.Vector(ox, oy, z - 1)))
            else:
                a = hole_size / 2.0
                R = a / math.cos(math.pi / sides)
                w = make_polygon_wire_xy(sides, R, z - 1)
                w.translate(App.Vector(ox, oy, 0))
            hole = Part.Face(w).extrude(App.Vector(0, 0, thickness + 2))
            cuts.append(hole)

    if cuts:
        try:
            disc = disc.cut(Part.makeCompound(cuts))
        except Exception:
            for c in cuts:
                try:
                    disc = disc.cut(c)
                except Exception:
                    pass
    return disc


# ==============================================================================
# STEPPED CONNECTOR  -  inset male plug (with hemispherical pins), thick female
# socket (with an L-track). Male and female radii are derived from one set of
# helpers so the two always mate.
#
# Radial stack-up (looking down):
#     bore .... male_inner | male wall | male_outer ))pin(( ... track ... back
#     wall ... f_outer(=body OD)
#   female bore = body_OD - CONNECTOR_WALL          (wall thickened INWARD, so the
#                                                    collar OD stays = body OD and
#                                                    still passes the sock hole)
#   male_outer  = female bore - CONNECTOR_CLEARANCE
# ==============================================================================

def male_outer_radius(body_outer_r):
    """Outer radius of the male plug for a body of this outer radius."""
    return body_outer_r - CONNECTOR_WALL - CONNECTOR_CLEARANCE


def male_inner_radius(body_outer_r):
    """Bore radius of the male plug (and of whatever it welds to)."""
    return male_outer_radius(body_outer_r) - WALL_THICKNESS


def female_inner_radius(body_outer_r):
    """Bore radius of the female socket (wall thickened inward)."""
    return body_outer_r - CONNECTOR_WALL


def make_hemispherical_pin(surface_r, pin_r, z_center, angle_deg):
    """
    A half-sphere pin protruding radially from a cylinder wall. A full sphere is
    centered ON the wall's outer surface, so its inner half is buried in (and
    fuses to) the ring wall and its outer half stands proud as a clean dome.
    Round + symmetric in Z, so it prints the same whether the part is run this
    end up or flipped, and it slides/twists in the track without sharp corners.
    """
    sph = Part.makeSphere(pin_r, App.Vector(surface_r, 0.0, z_center))
    sph.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), angle_deg)
    return sph


def make_align_mark(outer_r, z_center, angle_deg, point_up=True,
                    size=5.0, depth=0.7):
    """
    A small engraved arrowhead cut into the outer surface at angle_deg, marking
    where the tab lines up with the entry slot. Point it toward the mating
    direction so the user lines the two marks up, pushes together, then twists.
    Returns a cutter solid (subtract it from the ring).
    """
    h = size / 2.0
    s = 1.0 if point_up else -1.0
    # triangle in the tangential(Y)-vertical(Z) plane, apex toward mating dir
    pts = [
        App.Vector(0, -h, z_center - s * h),
        App.Vector(0,  h, z_center - s * h),
        App.Vector(0,  0, z_center + s * h),
    ]
    pts.append(pts[0])
    face = Part.Face(Part.makePolygon(pts))
    cutter = face.extrude(App.Vector(depth + 0.5, 0, 0))
    cutter.translate(App.Vector(outer_r - depth, 0, 0))
    cutter.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), angle_deg)
    return cutter


def make_bottom_male_with_grate(outer_r, conn_h, pin_count, pin_r,
                                pattern, grate_hole, grate_web, grate_t):
    """
    The module's BOTTOM: an inset male plug sitting on the print bed (z=0), with
    the honeycomb grate welded into its base at z=0. Because the grate is the
    first layer on the bed there is NO void beneath it -> it prints with no
    support. The plug points DOWN in use and plugs into the FEMALE at the top of
    the module below. Water: media -> grate holes -> plug bore -> module below.

    The plug is narrower than the body wall (it has to fit the thick female
    bore). Instead of a flat shoulder ledge welding it out to the wall (whose
    underside would need supports), a CONE flares from the plug OD out to the
    body OD at a self-supporting angle. That is what lets the whole module print
    grate-down with NO supports on the outside, so nothing rough is left near the
    pins / lock.

    Returns (solid, top_z) where top_z is where the lattice chamber should begin.
    """
    m_outer = male_outer_radius(outer_r)
    m_inner = male_inner_radius(outer_r)

    # Male plug ring on the bed (z = 0 .. conn_h fully inserts into the female)
    ring = make_cylinder_shell(m_outer, m_inner, conn_h, 0.0)

    # Grate welded into the base, filling the plug bore, sitting on the bed
    grate = make_disc_grate(m_inner + 0.5, grate_web, grate_t, 0.0,
                            pattern, grate_hole, grate_web)
    try:
        ring = ring.fuse(grate)
    except Exception:
        pass

    # Hemispherical pins centered in the plug (z = conn_h/2), so the matching
    # centered lock track seats the plug fully when the pin reaches it.
    z_center = conn_h / 2.0
    for i in range(pin_count):
        ang = i * 360.0 / pin_count
        pin = make_hemispherical_pin(m_outer, pin_r, z_center, ang)
        try:
            ring = ring.fuse(pin)
        except Exception:
            pass

    # Conical shoulder ABOVE the plug: flares from the plug OD out to the body
    # wall. The rise is >= the flare so the outer face is <= 45 deg from vertical
    # and prints without support. Bore kept open for flow.
    cone_h = (outer_r - m_outer) + 1.5
    frustum = Part.makeCone(m_outer, outer_r, cone_h,
                            App.Vector(0, 0, conn_h - WELD))
    bore = Part.makeCylinder(m_inner, cone_h + 3 * WELD,
                             App.Vector(0, 0, conn_h - 2 * WELD))
    shoulder = frustum.cut(bore)
    try:
        ring = ring.fuse(shoulder)
    except Exception:
        pass

    chamber_z0 = conn_h - WELD + cone_h          # chamber begins at the cone top
    return ring, chamber_z0


def make_male_connector(outer_r, conn_h, pin_count, pin_r, z_top_of_body,
                        clearance_ext=0.0):
    """
    Inset male plug that rises ABOVE the body from z_top_of_body, used by the
    holder and the rect plate (the body itself is the seat/shoulder, so no
    separate shoulder is needed here). It overlaps the body slightly (WELD) for
    a solid weld. Hemispherical pins protrude radially mid-plug to engage the
    female lock. The pin is symmetric in Z, so it self-supports whether the part
    prints this end up or flipped (the rect plate prints males-up).

    clearance_ext lengthens the plug on the free (tip) end and carries the pin
    out with it, keeping the weld end fixed. A module locked onto this plug then
    hangs clearance_ext further from the plate/holder -> mounting clearance.
    """
    m_outer = male_outer_radius(outer_r)
    m_inner = male_inner_radius(outer_r)
    overlap = min(2.0, conn_h * 0.4)
    z_base = z_top_of_body - overlap - clearance_ext   # tip pushed out by the extension
    height = conn_h + overlap + clearance_ext          # ...weld end stays put

    ring = make_cylinder_shell(m_outer, m_inner, height, z_base)

    # Pins centered in the inserted (conn_h) portion, measured from the tip
    # (z_base), so they sit mid-plug and match the centered female lock track.
    # With clearance_ext the tip (and thus the pin) is pushed further out.
    z_center = z_base + conn_h / 2.0
    for i in range(pin_count):
        ang = i * 360.0 / pin_count
        pin = make_hemispherical_pin(m_outer, pin_r, z_center, ang)
        try:
            ring = ring.fuse(pin)
        except Exception:
            pass

    return ring


def make_top_female_connector(outer_r, conn_h, pin_count, pin_r, z_base):
    """
    Thick-walled female socket that rises ABOVE the body from z_base, opening
    UPWARD. The module above (or the holder) plugs its downward male into this.

    Each pin gets an L-track cut into the thick wall:
      * a VERTICAL ENTRY channel, open at the mouth (top) down to the lock level,
        that the dome slides down;
      * a HORIZONTAL LOCK channel swept across LOCK_TWIST_DEG at the lock level,
        with a SOLID ROOF above it that traps the dome axially once twisted. The
        channel TAPERS narrower toward the locked end, so the dome slides in
        easily and then grips and stays put - that grip is the lock, so there is
        no separate raised detent bump.

    The wall is thickened INWARD (bore = outer_r - CONNECTOR_WALL), so the collar
    OD stays equal to the body OD and still passes the sock hole. Prints cleanly
    socket-up: the mouth faces up and the roof is a short bridge over the channel.
    """
    f_inner = female_inner_radius(outer_r)
    f_outer = outer_r
    z_top = z_base + conn_h
    ring = make_cylinder_shell(f_outer, f_inner, conn_h, z_base)

    z_center = z_base + conn_h / 2.0                 # lock level (matches the pin)

    # Radial extent of the track: from just inside the bore out to clear the dome
    # tip, but never past the back wall.
    cut_in  = f_inner - 0.3
    cut_out = min(f_inner + (pin_r - CONNECTOR_CLEARANCE) + PIN_TRACK_CLEARANCE,
                  f_outer - CONNECTOR_BACK_WALL)
    cut_len = max(0.6, cut_out - cut_in)

    chan_h  = 2.0 * (pin_r + PIN_TRACK_CLEARANCE)    # channel height (Z): axial fit
    chan_w0 = chan_h                                  # entry width: loose, easy start
    chan_w1 = 2.0 * pin_r + 0.25                       # locked width: snug grip
    z_floor0 = z_center - chan_h / 2.0                 # full-height floor (entry/seat)

    # Lock channel profile (printed mouth-UP, so the roof is the overhang face):
    #   - a PARALLEL free-travel run right after the entry: constant full height
    #     AND constant loose width, so the pin slides freely and you get a real
    #     amount of twist before anything grips. This run is the only flat roof.
    #   - then the channel PINCHES toward a closed wedge: the roof ramps down and
    #     the floor ramps up about the lock level while the width narrows, so the
    #     pin wedges and holds. Lengthen LOCK_CHANNEL_FLAT for more free travel.
    flat_deg  = math.degrees(LOCK_CHANNEL_FLAT / max(f_inner, 1.0))   # parallel run arc
    total_deg = max(LOCK_TWIST_DEG, flat_deg + 4.0)                   # full sweep
    dome      = 2.0 * pin_r
    p_grip    = max(0.05, 1.0 - dome / chan_h)        # pinch fraction where it wedges

    for i in range(pin_count):
        ang = i * 360.0 / pin_count

        # 1. vertical entry: open from the mouth down to the channel floor
        ent_h = z_top - z_floor0 + 1.0
        ent = Part.makeBox(cut_len, chan_w0, ent_h,
                           App.Vector(cut_in, -chan_w0 / 2.0, z_floor0))
        ent.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), ang)
        try:
            ring = ring.cut(ent)
        except Exception:
            pass

        # 2. swept lock channel: parallel free-travel run, then converging wedge
        steps = max(8, int(total_deg / 1.5))
        for s in range(steps + 1):
            deg = total_deg * s / float(steps)
            if deg <= flat_deg:
                # parallel run: full height + loose width -> pin slides freely
                h = chan_h
                w = chan_w0
            else:
                # pinch: converge height (roof down + floor up) and narrow width;
                # both reach the pin's size together so it wedges and grips
                p = (deg - flat_deg) / (total_deg - flat_deg)   # 0 -> 1
                h = chan_h * (1.0 - p)
                w = chan_w0 + (chan_w1 - chan_w0) * min(1.0, p / p_grip)
            h = max(h, 0.4)
            fz = z_center - h / 2.0                              # floor rises as h shrinks
            t = ang - deg
            lb = Part.makeBox(cut_len, w, h,
                              App.Vector(cut_in, -w / 2.0, fz))
            lb.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), t)
            try:
                ring = ring.cut(lb)
            except Exception:
                pass

    # Alignment mark at the first entry, pointing up toward the mouth, so the
    # user lines it up with the male's mark before pushing together.
    try:
        ring = ring.cut(make_align_mark(f_outer, z_center, 0.0, point_up=True))
    except Exception:
        pass
    return ring




# ==============================================================================
# MODULE
# ==============================================================================

def make_module(x_offset=0):
    """One complete stackable basket module.

    Z-layout (printed grate-DOWN, i.e. as drawn, bottom on the bed):
      z = 0 .. CONNECTOR_HEIGHT          bottom male + grate (grate ON the bed)
      z = CONNECTOR_HEIGHT .. +chamber   lattice media chamber
      z = top .. top+CONNECTOR_HEIGHT    top female socket (opens up)
    The grate is the first layer on the bed, so nothing bridges -> no supports.
    """
    outer_r = MOD_DIAMETER / 2.0
    inner_r = outer_r - WALL_THICKNESS
    chamber_h = TOP_HEIGHT + BOTTOM_HEIGHT

    if chamber_h <= 0:
        raise ValueError("TOP_HEIGHT + BOTTOM_HEIGHT must be > 0.")

    conn_h = CONNECTOR_HEIGHT
    through_h = BOTTOM_HEIGHT            # open lattice band (just above the grate)

    parts = []

    # 1. Bottom male connector with the grate welded into its base (on the bed).
    bottom, chamber_z0 = make_bottom_male_with_grate(
        outer_r, conn_h, PIN_COUNT, PIN_RADIUS,
        HOLE_PATTERN, GRATE_HOLE_SIZE, GRATE_WEB, GRATE_THICKNESS)
    parts.append(bottom)

    # 2. Lattice media chamber, starting just above the bottom connector.
    wall = make_patterned_wall(outer_r, inner_r, chamber_h, through_h,
                               HOLE_PATTERN, HOLE_SIZE, HOLE_SPACING,
                               EMBOSS_DEPTH, z_base=chamber_z0)
    parts.append(wall)

    # 3. Top female socket (opens up; the module above / holder plugs down in).
    female = make_top_female_connector(outer_r, conn_h, PIN_COUNT, PIN_RADIUS,
                                       z_base=chamber_z0 + chamber_h)
    parts.append(female)

    result = parts[0]
    for p in parts[1:]:
        try:
            result = result.fuse(p)
        except Exception:
            pass

    result.translate(App.Vector(x_offset, 0, 0))
    obj = doc.addObject("Part::Feature", "Module")
    obj.Shape = result
    return obj


# ==============================================================================
# CIRCULAR HOLDER
#   NOTE: holders are carried over from v1 with their receivers updated to the
#   new connector. They still need their own review pass.
# ==============================================================================

def make_circular_holder(x_offset=0):
    hole_r  = SOCK_HOLE_DIAMETER / 2.0
    fit_c   = HOLDER_RIM_FIT_CLEARANCE
    lip_w   = CIRC_HOLDER_LIP_WIDTH
    rim_d   = CIRC_HOLDER_RIM_DEPTH
    rim_t   = CIRC_HOLDER_RIM_THICKNESS
    lip_t   = CIRC_HOLDER_FLOOR_THICKNESS

    mod_r   = MOD_DIAMETER / 2.0
    bore_r  = male_inner_radius(mod_r)         # bore = male inner so the male welds
                                               # to the rim and water flows straight

    rim_outer_r = hole_r - fit_c               # rim drops into the hole
    outer_r     = hole_r + lip_w               # lip overhangs the hole edge

    parts = []
    # Lip flange - rests on the baffle, overhangs the hole. Open bore in centre.
    parts.append(make_cylinder_shell(outer_r, bore_r, lip_t, 0))
    # Centering rim - drops into the hole, open bore so water passes straight down.
    parts.append(make_cylinder_shell(rim_outer_r, bore_r, rim_d, -rim_d))
    # Downward male on the underside: it plugs DOWN into the top module's female
    # socket, so the basket stack hangs below the holder, down into the sump.
    # z_top_of_body lifts the male top 2mm into the rim for a solid weld.
    male = make_male_connector(mod_r, CONNECTOR_HEIGHT, PIN_COUNT, PIN_RADIUS,
                               z_top_of_body=-rim_d - CONNECTOR_HEIGHT + 2.0,
                               clearance_ext=MOUNT_CLEARANCE)
    parts.append(male)

    result = parts[0]
    for p in parts[1:]:
        try:
            result = result.fuse(p)
        except Exception:
            pass
    result.translate(App.Vector(x_offset, 0, 0))
    obj = doc.addObject("Part::Feature", "CircularHolder")
    obj.Shape = result
    return obj


# ==============================================================================
# RECTANGULAR HOLDER  -  two pieces that each print support-free
# ==============================================================================

def _rect_wire(W, D, z):
    pts = [App.Vector(-W/2, -D/2, z), App.Vector(W/2, -D/2, z),
           App.Vector(W/2,  D/2, z), App.Vector(-W/2,  D/2, z)]
    pts.append(pts[0])
    return Part.makePolygon(pts)


def _rect_can_positions():
    """(cx, cy) centres of the tower cans on the rectangular plate. Shared by the
    frame (to relieve the seat under each ring) and the plate (bores + males) so
    the two always line up. Cans are packed on a grid, then pushed outward toward
    the short ends by RECT_CAN_SPREAD for room to twist them on."""
    rt = RECT_HOLDER_RIM_THICKNESS
    Wi = RECT_HOLDER_WIDTH - 2 * rt
    Di = RECT_HOLDER_DEPTH - 2 * rt
    cell = MOD_DIAMETER + STACK_CLEARANCE
    cols = max(1, int((Wi - STACK_CLEARANCE) / cell))
    rows = max(1, int((Di - STACK_CLEARANCE) / cell))
    grid_w = cols * cell - STACK_CLEARANCE
    grid_d = rows * cell - STACK_CLEARANCE
    sx = -grid_w / 2 + MOD_DIAMETER / 2
    sy = -grid_d / 2 + MOD_DIAMETER / 2
    pos = []
    for r in range(rows):
        for c in range(cols):
            cx = sx + c * cell
            cy = sy + r * cell
            if cx > 1e-6:
                cx += RECT_CAN_SPREAD
            elif cx < -1e-6:
                cx -= RECT_CAN_SPREAD
            pos.append((cx, cy))
    return pos


def make_rectangular_frame(x_offset=0):
    """
    Lip + sides only. The inner opening has a 45-deg bevel seat at the bottom
    that the drop-in plate catches on. Prints LIP-DOWN: the bevel then becomes a
    self-supporting overhang and the lip sits flat on the bed -> no supports.
    """
    w       = RECT_HOLDER_WIDTH
    d       = RECT_HOLDER_DEPTH
    lip_x   = RECT_HOLDER_LIP_X
    lip_y   = RECT_HOLDER_LIP_Y
    rim_d   = RECT_HOLDER_RIM_DEPTH
    rim_t   = RECT_HOLDER_RIM_THICKNESS
    lip_t   = RECT_HOLDER_FLOOR_THICKNESS
    bevel   = RECT_PLATE_BEVEL

    total_w = w + lip_x * 2
    total_d = d + lip_y * 2
    Wi = w - rim_t * 2          # rim inner opening
    Di = d - rim_t * 2

    # Solid block: rim (z -rim_d..0) + lip flange (z 0..lip_t)
    block = Part.makeBox(w, d, rim_d, App.Vector(-w/2, -d/2, -rim_d))
    lip = Part.makeBox(total_w, total_d, lip_t, App.Vector(-total_w/2, -total_d/2, 0))
    frame = block.fuse(lip)

    # Straight bore from the top of the bevel up through the lip
    bore = Part.makeBox(Wi, Di, rim_d + lip_t + 2,
                        App.Vector(-Wi/2, -Di/2, -rim_d + bevel))
    frame = frame.cut(bore)

    # 45-deg bevel seat at the very bottom (frustum void: wide at top, narrow at
    # bottom). The plate's chamfered edge rests on this slope.
    try:
        w_top = _rect_wire(Wi, Di, -rim_d + bevel)
        w_bot = _rect_wire(Wi - 2*bevel, Di - 2*bevel, -rim_d)
        frustum = Part.makeLoft([w_bot, w_top], True)
        frame = frame.cut(frustum)
    except Exception:
        pass

    # Relieve the bevel seat where each can's connector ring passes through, so
    # the plate can seat flush. The 91 mm module's ~82 mm ring is wider than the
    # seat opening, so without this it hangs up on the seat. Same centres as the
    # plate. The plate still rests on the seat at the short ends and the centre.
    mod_r  = MOD_DIAMETER / 2.0
    ring_r = male_outer_radius(mod_r) + RECT_RING_GAP
    for (cx, cy) in _rect_can_positions():
        relief = Part.makeCylinder(ring_r, bevel + 6.0,
                                   App.Vector(cx, cy, -rim_d - 2.0))
        try:
            frame = frame.cut(relief)
        except Exception:
            pass

    frame.translate(App.Vector(x_offset, 0, 0))
    obj = doc.addObject("Part::Feature", "RectangularFrame")
    obj.Shape = frame
    return obj


def make_rectangular_plate(x_offset=0):
    """
    The drop-in plate: a flat plate with a 45-deg chamfered edge that seats on
    the frame bevel, a grid of drain bores, and a downward male at each bore.
    Prints FLAT, males-UP (flip it to drop into the frame): the chamfer and the
    male tabs are oriented to self-support in that flipped print -> no supports.
    Built here in USE orientation (males down) seated where it lands in the frame.
    """
    w       = RECT_HOLDER_WIDTH
    d       = RECT_HOLDER_DEPTH
    rim_d   = RECT_HOLDER_RIM_DEPTH
    rim_t   = RECT_HOLDER_RIM_THICKNESS
    bevel   = RECT_PLATE_BEVEL
    clr     = RECT_PLATE_CLEARANCE
    plate_t = RECT_PLATE_THICKNESS

    Wi = w - rim_t * 2
    Di = d - rim_t * 2
    pw = Wi - 2 * clr           # plate slides into the bore
    pd = Di - 2 * clr
    z0 = -rim_d                 # chamfer bottom rests on the seat bottom

    # Flat plate with a 45-deg chamfer around its bottom edge, matching the
    # frame's 45-deg seat so the two slope faces mate flush when dropped in.
    chamf = min(bevel, plate_t - 1.0)
    plate = Part.makeBox(pw, pd, plate_t, App.Vector(-pw/2, -pd/2, z0))
    try:
        bottom_edges = [e for e in plate.Edges
                        if abs(e.Vertexes[0].Point.z - z0) < 1e-6
                        and abs(e.Vertexes[1].Point.z - z0) < 1e-6]
        plate = plate.makeChamfer(chamf, bottom_edges)
    except Exception:
        # Fallback: lofted chamfer base + upper slab (chamf < plate_t guaranteed)
        try:
            w_bot = _rect_wire(pw - 2*chamf, pd - 2*chamf, z0)
            w_top = _rect_wire(pw, pd, z0 + chamf)
            base = Part.makeLoft([w_bot, w_top], True)
            upper = Part.makeBox(pw, pd, plate_t - chamf,
                                 App.Vector(-pw/2, -pd/2, z0 + chamf))
            plate = base.fuse(upper)
        except Exception:
            pass

    mod_r   = MOD_DIAMETER / 2.0
    bore_r  = male_inner_radius(mod_r)
    plate_bottom = z0
    males = []
    for (cx, cy) in _rect_can_positions():
        drain = Part.makeCylinder(bore_r, plate_t + bevel + 2,
                                  App.Vector(cx, cy, z0 - 1))
        plate = plate.cut(drain)
        # Downward male; the dome pin is symmetric in Z, so it self-supports
        # when the plate is printed flipped (males-up).
        male = make_male_connector(mod_r, CONNECTOR_HEIGHT, PIN_COUNT,
                                   PIN_RADIUS,
                                   z_top_of_body=plate_bottom - CONNECTOR_HEIGHT + 2.0,
                                   clearance_ext=MOUNT_CLEARANCE)
        male.translate(App.Vector(cx, cy, 0))
        males.append(male)

    result = plate
    for m in males:
        try:
            result = result.fuse(m)
        except Exception:
            pass

    result.translate(App.Vector(x_offset, 0, 0))
    obj = doc.addObject("Part::Feature", "RectangularPlate")
    obj.Shape = result
    return obj


def make_floss_support(x_offset=0):
    """
    Flat perforated rectangle that lies inside the frame, resting on top of the
    base plate, so the filter floss has a supported surface. Uses the same hole
    pattern (shape/size/spacing) as the cup modules, framed by a solid border.
    Same footprint as the plate, so it drops into the frame and sits on the
    plate. Prints flat, no supports.
    """
    rt  = RECT_HOLDER_RIM_THICKNESS
    clr = RECT_PLATE_CLEARANCE
    ow  = RECT_HOLDER_WIDTH - 2.0 * rt - 2.0 * clr      # matches the plate footprint
    od  = RECT_HOLDER_DEPTH  - 2.0 * rt - 2.0 * clr
    th  = FLOSS_SUPPORT_THICKNESS
    border = FLOSS_SUPPORT_BORDER

    slab = Part.makeBox(ow, od, th, App.Vector(-ow / 2.0, -od / 2.0, 0))

    sides = pattern_name_to_sides(HOLE_PATTERN)
    cell  = HOLE_SIZE + HOLE_SPACING
    a     = HOLE_SIZE / 2.0
    reach = a / math.cos(math.pi / sides) if sides >= 3 else a   # keep holes off border
    xlim  = ow / 2.0 - border - reach                            # centres stay inside here
    ylim  = od / 2.0 - border - reach
    row_pitch = cell * (math.sqrt(3.0) / 2.0 if sides == 6 else 1.0)
    ncol = max(0, int((ow) / cell) + 2)
    nrow = max(0, int((od) / max(row_pitch, 1e-6)) + 2)

    cuts = []
    for row in range(-nrow, nrow + 1):
        for col in range(-ncol, ncol + 1):
            if sides in (4, 6):
                ox = col * cell + (cell / 2.0 if row % 2 else 0.0)
                oy = row * row_pitch
            else:
                ox = col * cell
                oy = row * cell
            if abs(ox) > xlim or abs(oy) > ylim:
                continue
            if sides == 0:
                w = Part.Wire(Part.makeCircle(a, App.Vector(ox, oy, -1)))
            else:
                R = a / math.cos(math.pi / sides)
                w = make_polygon_wire_xy(sides, R, -1)
                w.translate(App.Vector(ox, oy, 0))
            cuts.append(Part.Face(w).extrude(App.Vector(0, 0, th + 2)))

    if cuts:
        try:
            slab = slab.cut(Part.makeCompound(cuts))
        except Exception:
            for c in cuts:
                try:
                    slab = slab.cut(c)
                except Exception:
                    pass

    slab.translate(App.Vector(x_offset, 0, 0))
    obj = doc.addObject("Part::Feature", "FlossSupport")
    obj.Shape = slab
    return obj


# ==============================================================================
# PARAMETER DIALOG
# ==============================================================================
#
# A Qt dialog lets the user pick which of the four objects to build and edit
# every parameter (defaulted to the constants above). On OK the edited values
# are written back to the module globals and the derived module diameter is
# recomputed; on Cancel nothing is built.

_QT_OK = True
try:
    from PySide2 import QtWidgets, QtCore          # FreeCAD on Qt5
except Exception:
    try:
        from PySide6 import QtWidgets, QtCore      # FreeCAD on Qt6
    except Exception:
        try:
            from PySide import QtGui as QtWidgets   # very old PySide (widgets in QtGui)
            from PySide import QtCore
        except Exception:
            _QT_OK = False

# (constant_name, kind, choices)  kind in {"float", "int", "choice"}
_PARAM_GROUPS = [
    ("Sock hole & module", [
        ("SOCK_HOLE_DIAMETER", "float", None),
        ("MODULE_HOLE_CLEARANCE", "float", None),
        ("WALL_THICKNESS", "float", None),
        ("TOP_HEIGHT", "float", None),
        ("BOTTOM_HEIGHT", "float", None),
    ]),
    ("Twist-lock connector", [
        ("CONNECTOR_HEIGHT", "float", None),
        ("CONNECTOR_WALL", "float", None),
        ("CONNECTOR_CLEARANCE", "float", None),
        ("PIN_COUNT", "int", None),
        ("PIN_RADIUS", "float", None),
        ("PIN_TRACK_CLEARANCE", "float", None),
        ("CONNECTOR_BACK_WALL", "float", None),
        ("LOCK_TWIST_DEG", "float", None),
        ("LOCK_CHANNEL_FLAT", "float", None),
        ("MOUNT_CLEARANCE", "float", None),
    ]),
    ("Wall pattern", [
        ("HOLE_PATTERN", "choice", ["hexagon", "circle", "square", "pentagon"]),
        ("HOLE_SIZE", "float", None),
        ("HOLE_SPACING", "float", None),
        ("EMBOSS_DEPTH", "float", None),
    ]),
    ("Bottom grate", [
        ("GRATE_HOLE_SIZE", "float", None),
        ("GRATE_WEB", "float", None),
        ("GRATE_THICKNESS", "float", None),
    ]),
    ("Circular holder", [
        ("HOLDER_RIM_FIT_CLEARANCE", "float", None),
        ("CIRC_HOLDER_LIP_WIDTH", "float", None),
        ("CIRC_HOLDER_RIM_DEPTH", "float", None),
        ("CIRC_HOLDER_RIM_THICKNESS", "float", None),
        ("CIRC_HOLDER_FLOOR_THICKNESS", "float", None),
    ]),
    ("Rectangular holder", [
        ("RECT_HOLDER_WIDTH", "float", None),
        ("RECT_HOLDER_DEPTH", "float", None),
        ("RECT_HOLDER_LIP_X", "float", None),
        ("RECT_HOLDER_LIP_Y", "float", None),
        ("RECT_HOLDER_RIM_DEPTH", "float", None),
        ("RECT_HOLDER_RIM_THICKNESS", "float", None),
        ("RECT_HOLDER_FLOOR_THICKNESS", "float", None),
        ("RECT_PLATE_THICKNESS", "float", None),
        ("RECT_PLATE_BEVEL", "float", None),
        ("RECT_PLATE_CLEARANCE", "float", None),
        ("STACK_CLEARANCE", "float", None),
        ("RECT_CAN_SPREAD", "float", None),
        ("RECT_RING_GAP", "float", None),
    ]),
    ("Floss support", [
        ("FLOSS_SUPPORT_THICKNESS", "float", None),
        ("FLOSS_SUPPORT_BORDER", "float", None),
    ]),
]

# (checkbox label, build-flag constant name)
_OBJECTS = [
    ("Module", "BUILD_MODULE"),
    ("Circular holder", "BUILD_CIRCULAR_HOLDER"),
    ("Rectangular frame", "BUILD_RECTANGULAR_FRAME"),
    ("Rectangular plate", "BUILD_RECTANGULAR_PLATE"),
    ("Floss support", "BUILD_FLOSS_SUPPORT"),
]


def show_param_dialog():
    """Show the parameter dialog.

    Returns a dict {build_flag: bool} on OK (after writing edited values back to
    the module globals and recomputing MOD_DIAMETER), or None if cancelled.
    """
    g = globals()
    dlg = QtWidgets.QDialog()
    dlg.setWindowTitle("Reef Filter System")
    outer = QtWidgets.QVBoxLayout(dlg)

    # --- object checkboxes ---
    obj_box = QtWidgets.QGroupBox("Objects to create")
    obj_lay = QtWidgets.QVBoxLayout(obj_box)
    obj_checks = {}
    for label, flag in _OBJECTS:
        cb = QtWidgets.QCheckBox(label)
        cb.setChecked(bool(g.get(flag, True)))
        obj_lay.addWidget(cb)
        obj_checks[flag] = cb
    outer.addWidget(obj_box)

    # --- scrollable parameter fields ---
    scroll = QtWidgets.QScrollArea()
    scroll.setWidgetResizable(True)
    content = QtWidgets.QWidget()
    vbox = QtWidgets.QVBoxLayout(content)
    fields = {}
    for group_name, items in _PARAM_GROUPS:
        gb = QtWidgets.QGroupBox(group_name)
        form = QtWidgets.QFormLayout(gb)
        for name, kind, choices in items:
            default = g.get(name)
            if kind == "choice":
                w = QtWidgets.QComboBox()
                w.addItems(choices)
                if default in choices:
                    w.setCurrentIndex(choices.index(default))
            else:
                w = QtWidgets.QLineEdit(str(default))
            form.addRow(name, w)
            fields[name] = (w, kind)
        vbox.addWidget(gb)
    scroll.setWidget(content)
    outer.addWidget(scroll)

    # --- plain OK / Cancel buttons (avoids cross-version enum issues) ---
    row = QtWidgets.QHBoxLayout()
    cancel_btn = QtWidgets.QPushButton("Cancel")
    ok_btn = QtWidgets.QPushButton("Create")
    ok_btn.setDefault(True)
    cancel_btn.clicked.connect(dlg.reject)
    ok_btn.clicked.connect(dlg.accept)
    row.addStretch()
    row.addWidget(cancel_btn)
    row.addWidget(ok_btn)
    outer.addLayout(row)

    dlg.resize(440, 620)

    res = dlg.exec_() if hasattr(dlg, "exec_") else dlg.exec()
    if not res:                                  # Cancelled / closed
        return None

    # write edited values back to the globals
    for name, (w, kind) in fields.items():
        if kind == "choice":
            g[name] = w.currentText()
        elif kind == "int":
            try:
                g[name] = int(round(float(w.text())))
            except Exception:
                pass                             # keep existing default on bad input
        else:
            try:
                g[name] = float(w.text())
            except Exception:
                pass
    # recompute the derived module diameter from the (possibly edited) inputs
    g["MOD_DIAMETER"] = g["SOCK_HOLE_DIAMETER"] - 2.0 * g["MODULE_HOLE_CLEARANCE"]

    return {flag: cb.isChecked() for flag, cb in obj_checks.items()}


# ==============================================================================
# BUILD
# ==============================================================================

# Build into the CURRENT active document (create one only if none is open).
doc = App.activeDocument()
if doc is None:
    doc = App.newDocument("ReefFilterSystem")

# Gather build flags from the dialog. If the GUI/dialog is unavailable, fall
# back to the constants. If the user cancels, build nothing.
flags = None
if _QT_OK:
    try:
        flags = show_param_dialog()
    except Exception as e:
        App.Console.PrintWarning(
            "Reef Filter System: dialog failed (%s); using defaults.\n" % e)
        flags = {f: g for f, g in (
            ("BUILD_MODULE", BUILD_MODULE),
            ("BUILD_CIRCULAR_HOLDER", BUILD_CIRCULAR_HOLDER),
            ("BUILD_RECTANGULAR_FRAME", BUILD_RECTANGULAR_FRAME),
            ("BUILD_RECTANGULAR_PLATE", BUILD_RECTANGULAR_PLATE),
            ("BUILD_FLOSS_SUPPORT", BUILD_FLOSS_SUPPORT))}
else:
    flags = {"BUILD_MODULE": BUILD_MODULE,
             "BUILD_CIRCULAR_HOLDER": BUILD_CIRCULAR_HOLDER,
             "BUILD_RECTANGULAR_FRAME": BUILD_RECTANGULAR_FRAME,
             "BUILD_RECTANGULAR_PLATE": BUILD_RECTANGULAR_PLATE,
             "BUILD_FLOSS_SUPPORT": BUILD_FLOSS_SUPPORT}

if flags is None:
    App.Console.PrintMessage("Reef Filter System: cancelled, nothing built.\n")
else:
    x = 0.0
    if flags.get("BUILD_MODULE"):
        make_module(x_offset=x)
        x += MOD_DIAMETER + LAYOUT_SPACING
    if flags.get("BUILD_CIRCULAR_HOLDER"):
        make_circular_holder(x_offset=x)
        x += SOCK_HOLE_DIAMETER + CIRC_HOLDER_LIP_WIDTH * 2 + LAYOUT_SPACING
    if flags.get("BUILD_RECTANGULAR_FRAME"):
        make_rectangular_frame(x_offset=x)
        x += RECT_HOLDER_WIDTH + RECT_HOLDER_LIP_X * 2 + LAYOUT_SPACING
    if flags.get("BUILD_RECTANGULAR_PLATE"):
        make_rectangular_plate(x_offset=x)
        x += RECT_HOLDER_WIDTH + RECT_HOLDER_LIP_X * 2 + LAYOUT_SPACING
    if flags.get("BUILD_FLOSS_SUPPORT"):
        make_floss_support(x_offset=x)
        x += RECT_HOLDER_WIDTH + RECT_HOLDER_LIP_X * 2 + LAYOUT_SPACING

    doc.recompute()
    try:
        Gui.activeDocument().activeView().viewIsometric()
        Gui.SendMsgToActiveView("ViewFit")
    except Exception:
        pass

    print("Reef Filter System built into '%s'." % doc.Name)
    print("  Module diameter:     %.1f mm" % MOD_DIAMETER)
    print("  Module total height: %.1f mm" % (TOP_HEIGHT + BOTTOM_HEIGHT))
    print("  Pattern:             %s (through bottom %.1f mm, emboss top %.1f mm)"
          % (HOLE_PATTERN, BOTTOM_HEIGHT, TOP_HEIGHT))
    print("  PRINT MODULE grate-DOWN (as drawn): grate is the first bed layer, no supports.")
    print("  PRINT RECT FRAME lip-DOWN: the 45-deg seat becomes a self-supporting overhang.")
    print("  PRINT RECT PLATE flat, males-UP (flip it): chamfer + tabs self-support.")
    print("  ASSEMBLE rect holder: drop the plate into the frame; it catches on the bevel seat.")
