Source code for bosl2.bottlecaps

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: bosl2/bottlecaps.py
#    Pure-Python port of the standard soda-bottle threadings from BOSL2's bottlecaps.scad: the
#    PCO-1810 and PCO-1881 necks and caps. The :class:`BottleCaps` class exposes them as static
#    methods returning :class:`~bosl2.shapes3d.Bosl2Solid` geometry -- a neck to graft onto a bottle
#    body, and a matching cap.
#
#    The neck profile (inner bore, support ring, tamper-ring channel and sealing lip) is built the
#    same way BOSL2 does: a :func:`~bosl2.drawing.turtle` outline revolved with rotate_extrude. The
#    threads use this package's :meth:`~bosl2.threading.Threading.thread_helix`, with the two thread
#    breaks cut by the same zrot_copies-placed prismoids as BOSL2.
#
#    Approximations (this port's threading/cyl lack a few BOSL2 features): the thread lead-in
#    ``taper`` is not applied, cap threads are built without the ``internal=`` flank flip, and the
#    ``knurled``/``ribbed`` cap surface textures fall back to a plain wall (VNF texturing is not in
#    this port). The named-anchor system is not reproduced; geometry is anchored bottom-on-origin.
#    Not ported (follow-ups): generic_bottle_neck/cap, the bottle adapters, and the SPI (sp_) threads.
#
# FileSummary: PCO-1810 / PCO-1881 bottle necks and caps.
# FileGroup: BOSL2

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

from bosl2._helpers import union
from bosl2._native import native
from bosl2.constants import BOTTOM, RIGHT
from bosl2.distributors import zrot_copies
from bosl2.drawing import turtle
from bosl2.shapes3d import Bosl2Solid, cyl, prismoid
from bosl2.threading import Threading

if TYPE_CHECKING:  # real stub-typed imports for the checker (identical to pre-lazy)
    from pythonscad import polygon as _opolygon
    from pythonscad import rotate_extrude as _orotate_extrude
else:
    _opolygon = native("polygon")
    _orotate_extrude = native("rotate_extrude")

__all__ = ["BottleCaps", "BottleThreadSpec"]


@dataclass(frozen=True)
class BottleThreadSpec:
    """All dimensions (mm) of one bottle threading's neck and cap, from bottlecaps.scad."""

    # -- neck profile --
    inner_d: float
    neck_d: float
    neck_h: float
    support_d: float
    support_width: float
    support_rad: float
    support_h: float
    support_ang: float
    tamper_ring_d: float
    tamper_ring_width: float
    tamper_base_d: float
    tamper_base_h: float
    threadbase_d: float
    thread_pitch: float
    flank_angle: float
    thread_od: float
    lip_d: float
    lip_h: float
    lip_leadin_r: float
    lip_recess_d: float
    lip_recess_h: float
    lip_roundover_r: float
    neck_turns: float
    # -- cap --
    cap_id: float
    cap_tamper_ring_h: float
    cap_thread_od: float
    cap_thread_pitch: float
    cap_flank_angle: float
    cap_thread_depth: float
    cap_turns: float
    # -- variant-specific (only one threading uses each) --
    tamper_ring_r: float | None = None  # PCO-1810 tamper-ring corner radius
    tamper_ring_ang: float | None = None  # PCO-1881 tamper-ring flank angle
    tamper_divot_r: float | None = None  # PCO-1881 tamper divot radius


# PCO-1810 and PCO-1881 neck/cap dimensions (mm), transcribed from bottlecaps.scad.
_PCO1810 = BottleThreadSpec(
    inner_d=21.74,
    neck_d=26.19,
    neck_h=5.00,
    support_d=33.00,
    support_width=1.45,
    support_rad=0.40,
    support_h=21.00,
    support_ang=16,
    tamper_ring_d=27.97,
    tamper_ring_width=0.50,
    tamper_base_d=25.71,
    tamper_base_h=14.10,
    threadbase_d=24.51,
    thread_pitch=3.18,
    flank_angle=20,
    thread_od=27.43,
    lip_d=25.07,
    lip_h=1.70,
    lip_leadin_r=0.20,
    lip_recess_d=24.94,
    lip_recess_h=1.00,
    lip_roundover_r=0.58,
    neck_turns=810,
    cap_id=28.58,
    cap_tamper_ring_h=14.10,
    cap_thread_od=28.58,
    cap_thread_pitch=3.18,
    cap_flank_angle=20,
    cap_thread_depth=1.6,
    cap_turns=810,
    tamper_ring_r=1.60,
)
_PCO1881 = BottleThreadSpec(
    inner_d=21.74,
    neck_d=26.19,
    neck_h=5.00,
    support_d=33.00,
    support_width=0.58,
    support_rad=0.30,
    support_h=17.00,
    support_ang=15,
    tamper_ring_d=28.00,
    tamper_ring_width=0.30,
    tamper_base_d=25.71,
    tamper_base_h=11.20,
    threadbase_d=24.20,
    thread_pitch=2.70,
    flank_angle=15,
    thread_od=27.4,
    lip_d=25.07,
    lip_h=1.70,
    lip_leadin_r=0.30,
    lip_recess_d=24.94,
    lip_recess_h=1.00,
    lip_roundover_r=0.58,
    neck_turns=650,
    cap_id=28.58,
    cap_tamper_ring_h=11.20,
    cap_thread_od=25.5,
    cap_thread_pitch=2.70,
    cap_flank_angle=15,
    cap_thread_depth=1.6,
    cap_turns=650,
    tamper_ring_ang=45,
    tamper_divot_r=1.08,
)


def _turtle_start(x, y=0.0):
    """Full turtle state starting at (x, y) heading +X (this port's turtle needs full state)."""
    return [[[float(x), float(y)]], [1.0, 0.0], 90.0, 0.0]


def _pco1810_profile(diameter: "BottleThreadSpec"):
    height = diameter.support_h + diameter.neck_h
    return turtle(
        [
            "untilx",
            diameter.neck_d / 2,
            "left",
            90,
            "move",
            diameter.neck_h - 1,
            "arcright",
            1,
            90,
            "untilx",
            diameter.support_d / 2 - diameter.support_rad,
            "arcleft",
            diameter.support_rad,
            90,
            "move",
            diameter.support_width,
            "arcleft",
            diameter.support_rad,
            90 - diameter.support_ang,
            "untilx",
            diameter.tamper_base_d / 2,
            "right",
            90 - diameter.support_ang,
            "untily",
            height - diameter.tamper_base_h,
            "right",
            90,
            "untilx",
            diameter.tamper_ring_d / 2,
            "left",
            90,
            "move",
            diameter.tamper_ring_width,
            "arcleft",
            diameter.tamper_ring_r,
            90,
            "untilx",
            diameter.threadbase_d / 2,
            "right",
            90,
            "untily",
            height - diameter.lip_h - diameter.lip_leadin_r,
            "arcright",
            diameter.lip_leadin_r,
            90,
            "untilx",
            diameter.lip_d / 2,
            "left",
            90,
            "untily",
            height - diameter.lip_recess_h,
            "left",
            90,
            "untilx",
            diameter.lip_recess_d / 2,
            "right",
            90,
            "untily",
            height - diameter.lip_roundover_r,
            "arcleft",
            diameter.lip_roundover_r,
            90,
            "untilx",
            diameter.inner_d / 2,
        ],
        state=_turtle_start(diameter.inner_d / 2),
    )


def _pco1881_profile(diameter: "BottleThreadSpec"):
    height = diameter.support_h + diameter.neck_h
    return turtle(
        [
            "untilx",
            diameter.neck_d / 2,
            "left",
            90,
            "move",
            diameter.neck_h - 1,
            "arcright",
            1,
            90,
            "untilx",
            diameter.support_d / 2 - diameter.support_rad,
            "arcleft",
            diameter.support_rad,
            90,
            "move",
            diameter.support_width,
            "arcleft",
            diameter.support_rad,
            90 - diameter.support_ang,
            "untilx",
            diameter.tamper_base_d / 2,
            "arcright",
            diameter.tamper_divot_r,
            180 - diameter.support_ang * 2,
            "left",
            90 - diameter.support_ang,
            "untily",
            height - diameter.tamper_base_h,
            "right",
            90,
            "untilx",
            diameter.tamper_ring_d / 2,
            "left",
            90,
            "move",
            diameter.tamper_ring_width,
            "left",
            diameter.tamper_ring_ang,
            "untilx",
            diameter.threadbase_d / 2,
            "right",
            diameter.tamper_ring_ang,
            "untily",
            height - diameter.lip_h - diameter.lip_leadin_r,
            "arcright",
            diameter.lip_leadin_r,
            90,
            "untilx",
            diameter.lip_d / 2,
            "left",
            90,
            "untily",
            height - diameter.lip_recess_h,
            "left",
            90,
            "untilx",
            diameter.lip_recess_d / 2,
            "right",
            90,
            "untily",
            height - diameter.lip_roundover_r,
            "arcleft",
            diameter.lip_roundover_r,
            90,
            "untilx",
            diameter.inner_d / 2,
        ],
        state=_turtle_start(diameter.inner_d / 2),
    )


def _neck_thread(diameter: "BottleThreadSpec"):
    """The neck's external thread ridge with its two thread breaks (BOSL2 thread_helix + prismoids).

    The lead-in ``taper`` BOSL2 applies is not reproduced (this port's thread_helix has no taper).
    """
    thread_h = (diameter.thread_od - diameter.threadbase_d) / 2
    turns = diameter.neck_turns / 360
    thread = Threading.thread_helix(
        d=diameter.threadbase_d - 0.1,
        pitch=diameter.thread_pitch,
        thread_depth=thread_h + 0.1,
        flank_angle=diameter.flank_angle,
        turns=turns,
    )
    thread = thread.down(turns * diameter.thread_pitch / 2)  # BOSL2 anchor=TOP: top at z=0
    top = 1.82 + 2 * math.sin(math.radians(29)) * thread_h
    cuts = []
    for m_out in zrot_copies(rots=[90, 270]):
        for m_in in zrot_copies(rots=[-28, 28], radius=diameter.threadbase_d / 2):
            block = prismoid(
                [20, 1.82],
                [20, top],
                height=thread_h + 0.1,
                anchor=BOTTOM,
                orient=RIGHT,
            )
            cuts.append(block.multmatrix((m_out @ m_in).tolist()))
    return thread - union(cuts)


def _build_neck(diameter: "BottleThreadSpec", profile, bottom_half: bool):
    height = diameter.support_h + diameter.neck_h
    body = Bosl2Solid(
        _orotate_extrude(_opolygon([[float(x), float(y)] for x, y in profile])),
        size=[diameter.support_d, diameter.support_d, height],
    )
    thread = _neck_thread(diameter)
    if bottom_half:
        thread = thread.bottom_half()
    thread = thread.up(height - diameter.lip_h)
    return Bosl2Solid((body | thread).shape, size=[diameter.support_d, diameter.support_d, height])


def _build_cap(diameter: "BottleThreadSpec", wall: float, texture: str):
    w = diameter.cap_id + 2 * wall
    height = diameter.cap_tamper_ring_h + wall
    outer = cyl(diameter=w, length=height, anchor=BOTTOM)
    bore = cyl(diameter=diameter.cap_id, height=height, anchor=BOTTOM).up(wall)
    shell = outer - bore
    turns = diameter.cap_turns / 360
    H = turns * diameter.cap_thread_pitch
    # internal thread (this port's thread_helix has no internal= flank flip -- approximate).
    thread = Threading.thread_helix(
        d=diameter.cap_thread_od - diameter.cap_thread_depth * 2,
        pitch=diameter.cap_thread_pitch,
        thread_depth=diameter.cap_thread_depth,
        flank_angle=diameter.cap_flank_angle,
        turns=turns,
    )
    thread = thread.up(H / 2 + wall + 2)  # BOSL2 anchor=BOTTOM, then up(wall+2)
    cap = (shell | thread).rotate([0, 0, 45])
    return Bosl2Solid(cap.shape, size=[w, w, height])


[docs] class BottleCaps: """Standard soda-bottle necks and caps (BOSL2 bottlecaps.scad, PCO-1810 & PCO-1881). Each ``*_neck`` / ``*_cap`` returns a :class:`~bosl2.shapes3d.Bosl2Solid` anchored with its bottom on the XY plane. See the module docstring for the geometry approximations relative to BOSL2 (thread taper, internal-thread flank, and cap surface textures are not reproduced). """
[docs] @staticmethod def pco1810_neck(wall: float = 2) -> Bosl2Solid: """A PCO-1810 threaded beverage-bottle neck (BOSL2 pco1810_neck()).""" return _build_neck(_PCO1810, _pco1810_profile(_PCO1810), bottom_half=True)
[docs] @staticmethod def pco1810_cap(wall: float = 2, texture: str = "none") -> Bosl2Solid: """A cap for a PCO-1810 bottle (BOSL2 pco1810_cap()). ``texture`` other than ``"none"`` falls back to a plain wall (surface texturing is not in this port).""" return _build_cap(_PCO1810, wall, texture)
[docs] @staticmethod def pco1881_neck(wall: float = 2) -> Bosl2Solid: """A PCO-1881 threaded beverage-bottle neck (BOSL2 pco1881_neck()).""" return _build_neck(_PCO1881, _pco1881_profile(_PCO1881), bottom_half=False)
[docs] @staticmethod def pco1881_cap(wall: float = 2, texture: str = "none") -> Bosl2Solid: """A cap for a PCO-1881 bottle (BOSL2 pco1881_cap()). ``texture`` other than ``"none"`` falls back to a plain wall (surface texturing is not in this port).""" return _build_cap(_PCO1881, wall, texture)