Source code for pybosl2.parts.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: pybosl2/parts/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:`~pybosl2.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:`~pybosl2.drawing.turtle` outline revolved with rotate_extrude. The
#    threads use this package's :class:`~pybosl2.parts.threading.ThreadHelix`, 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.
# DocCategory: Parts library
# FileGroup: BOSL2

"""PCO-1810 / PCO-1881 bottle necks and caps."""

from __future__ import annotations

import math
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any

from pybosl2._helpers import union
from pybosl2._native import native
from pybosl2.constants import BOTTOM, RIGHT
from pybosl2.distributors import DistributableMatrix
from pybosl2.parts.threading import ThreadHelix
from pybosl2.shapes3d import Bosl2Solid, cyl, prismoid
from pybosl2.turtle import Turtle2DState, TurtleCommand, turtle2d
from pybosl2.turtle import TurtleCommandType as TCT  # noqa: N817

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", "BottleCapTexture"]


@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: float, y: float = 0.0) -> Turtle2DState:
    """Turtle state starting at (x, y) heading +X."""
    return Turtle2DState(path=[[float(x), float(y)]])


def _pco1810_profile(diameter: BottleThreadSpec) -> Any:
    height = diameter.support_h + diameter.neck_h
    return turtle2d(
        [
            TurtleCommand(TCT.UNTILX, size=diameter.neck_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.neck_h - 1),
            TurtleCommand(TCT.ARCRIGHT, radius=1, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.support_d / 2 - diameter.support_rad),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.support_rad, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.support_width),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.support_rad, angle=90 - diameter.support_ang),
            TurtleCommand(TCT.UNTILX, size=diameter.tamper_base_d / 2),
            TurtleCommand(TCT.RIGHT, angle=90 - diameter.support_ang),
            TurtleCommand(TCT.UNTILY, size=height - diameter.tamper_base_h),
            TurtleCommand(TCT.RIGHT, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.tamper_ring_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.tamper_ring_width),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.tamper_ring_r, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.threadbase_d / 2),
            TurtleCommand(TCT.RIGHT, angle=90),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_h - diameter.lip_leadin_r),
            TurtleCommand(TCT.ARCRIGHT, radius=diameter.lip_leadin_r, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.lip_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_recess_h),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.lip_recess_d / 2),
            TurtleCommand(TCT.RIGHT, angle=90),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_roundover_r),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.lip_roundover_r, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.inner_d / 2),
        ],
        state=_turtle_start(diameter.inner_d / 2),
    ).points()


def _pco1881_profile(diameter: BottleThreadSpec) -> Any:
    height = diameter.support_h + diameter.neck_h
    return turtle2d(
        [
            TurtleCommand(TCT.UNTILX, size=diameter.neck_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.neck_h - 1),
            TurtleCommand(TCT.ARCRIGHT, radius=1, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.support_d / 2 - diameter.support_rad),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.support_rad, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.support_width),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.support_rad, angle=90 - diameter.support_ang),
            TurtleCommand(TCT.UNTILX, size=diameter.tamper_base_d / 2),
            TurtleCommand(TCT.ARCRIGHT, radius=diameter.tamper_divot_r, angle=180 - diameter.support_ang * 2),
            TurtleCommand(TCT.LEFT, angle=90 - diameter.support_ang),
            TurtleCommand(TCT.UNTILY, size=height - diameter.tamper_base_h),
            TurtleCommand(TCT.RIGHT, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.tamper_ring_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.MOVE, size=diameter.tamper_ring_width),
            TurtleCommand(TCT.LEFT, angle=diameter.tamper_ring_ang),
            TurtleCommand(TCT.UNTILX, size=diameter.threadbase_d / 2),
            TurtleCommand(TCT.RIGHT, angle=diameter.tamper_ring_ang),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_h - diameter.lip_leadin_r),
            TurtleCommand(TCT.ARCRIGHT, radius=diameter.lip_leadin_r, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.lip_d / 2),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_recess_h),
            TurtleCommand(TCT.LEFT, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.lip_recess_d / 2),
            TurtleCommand(TCT.RIGHT, angle=90),
            TurtleCommand(TCT.UNTILY, size=height - diameter.lip_roundover_r),
            TurtleCommand(TCT.ARCLEFT, radius=diameter.lip_roundover_r, angle=90),
            TurtleCommand(TCT.UNTILX, size=diameter.inner_d / 2),
        ],
        state=_turtle_start(diameter.inner_d / 2),
    ).points()


def _neck_thread(
    diameter: BottleThreadSpec, fn: int | None = None, fa: float | None = None, fs: float | None = None
) -> Bosl2Solid:
    """Return 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_hx = ThreadHelix(
        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_hx.shape().down(turns * diameter.thread_pitch / 2)
    top = 1.82 + 2 * math.sin(math.radians(29)) * thread_h
    cuts = []
    for m_out in DistributableMatrix.zrot_copies(rots=[90, 270]):
        for m_in in DistributableMatrix.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,
                fn=fn,
                fa=fa,
                fs=fs,
            )
            cuts.append(block.multmatrix((m_out @ m_in).tolist()))
    return thread - union(cuts)  # type: ignore[no-any-return]


def _build_neck(
    diameter: BottleThreadSpec,
    profile: Any,
    bottom_half: bool,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> Bosl2Solid:
    height = diameter.support_h + diameter.neck_h
    body = Bosl2Solid(
        _orotate_extrude(_opolygon([[float(x), float(y)] for x, y in profile]), fn=fn),
        size=[diameter.support_d, diameter.support_d, height],
    )
    thread = _neck_thread(diameter, fn=fn, fa=fa, fs=fs)
    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])


class BottleCapTexture(Enum):
    """Bottle cap texture style."""

    NONE = "none"
    RIBS = "ribs"
    CHECKERS = "checkers"


def _build_cap(
    diameter: BottleThreadSpec,
    wall: float,
    texture: str | BottleCapTexture,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> Bosl2Solid:
    _ = texture.value if isinstance(texture, BottleCapTexture) else texture
    w = diameter.cap_id + 2 * wall
    height = diameter.cap_tamper_ring_h + wall
    outer = cyl(diameter=w, length=height, anchor=BOTTOM, fn=fn, fa=fa, fs=fs)
    bore = cyl(diameter=diameter.cap_id, height=height, anchor=BOTTOM, fn=fn, fa=fa, fs=fs).up(wall)
    shell = outer - bore
    turns = diameter.cap_turns / 360
    thread_height = turns * diameter.cap_thread_pitch
    # internal thread (this port's thread_helix has no internal= flank flip -- approximate).
    cap_thread = ThreadHelix(
        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_solid = cap_thread.shape().up(thread_height / 2 + wall + 2)
    cap = (shell | thread_solid).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:`~pybosl2.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). .. seealso:: `Visual spec sheet <specs/bottlecaps.html>`_ — measurements and STL previews """
[docs] @staticmethod def pco1810_neck(fn: int | None = None, fa: float | None = None, fs: float | None = None) -> Bosl2Solid: """Return a PCO-1810 threaded beverage-bottle neck (BOSL2 pco1810_neck()). Args: fn: Number of fragments for cylinder resolution. Passed to the geometry primitives. fa: Minimum fragment angle. Passed to the geometry primitives. fs: Minimum fragment size. Passed to the geometry primitives. Returns: A PCO-1810 bottle neck as a :class:`~pybosl2.shapes3d.Bosl2Solid` anchored bottom-on-origin. Examples: A standard PCO 1810 bottle neck (28 mm): .. pythonscad-example:: from pybosl2.parts.bottlecaps import BottleCaps BottleCaps.pco1810_neck(fa=6).show() """ return _build_neck(_PCO1810, _pco1810_profile(_PCO1810), bottom_half=True, fn=fn, fa=fa, fs=fs)
[docs] @staticmethod def pco1810_cap( wall: float = 2, texture: str | BottleCapTexture = BottleCapTexture.NONE, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """Return 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). Args: wall: Cap wall thickness in mm. Defaults to 2. texture: Cap surface texture style (NONE, RIBS, or CHECKERS). Defaults to NONE. fn: Number of fragments for cylinder resolution. Passed to the geometry primitives. fa: Minimum fragment angle. Passed to the geometry primitives. fs: Minimum fragment size. Passed to the geometry primitives. Returns: A PCO-1810 bottle cap as a :class:`~pybosl2.shapes3d.Bosl2Solid` anchored bottom-on-origin. Examples: A plain-walled cap for a PCO 1810 neck: .. pythonscad-example:: from pybosl2.parts.bottlecaps import BottleCaps BottleCaps.pco1810_cap(fa=6).show() """ return _build_cap(_PCO1810, wall, texture, fn=fn, fa=fa, fs=fs)
[docs] @staticmethod def pco1881_neck(fn: int | None = None, fa: float | None = None, fs: float | None = None) -> Bosl2Solid: """Return a PCO-1881 threaded beverage-bottle neck (BOSL2 pco1881_neck()). Args: fn: Number of fragments for cylinder resolution. Passed to the geometry primitives. fa: Minimum fragment angle. Passed to the geometry primitives. fs: Minimum fragment size. Passed to the geometry primitives. Returns: A PCO-1881 bottle neck as a :class:`~pybosl2.shapes3d.Bosl2Solid` anchored bottom-on-origin. Examples: A standard PCO 1881 bottle neck (38 mm): .. pythonscad-example:: from pybosl2.parts.bottlecaps import BottleCaps BottleCaps.pco1881_neck(fa=6).show() """ return _build_neck(_PCO1881, _pco1881_profile(_PCO1881), bottom_half=False, fn=fn, fa=fa, fs=fs)
[docs] @staticmethod def pco1881_cap( wall: float = 2, texture: str | BottleCapTexture = BottleCapTexture.NONE, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """Return 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). Args: wall: Cap wall thickness in mm. Defaults to 2. texture: Cap surface texture style (NONE, RIBS, or CHECKERS). Defaults to NONE. fn: Number of fragments for cylinder resolution. Passed to the geometry primitives. fa: Minimum fragment angle. Passed to the geometry primitives. fs: Minimum fragment size. Passed to the geometry primitives. Returns: A PCO-1881 bottle cap as a :class:`~pybosl2.shapes3d.Bosl2Solid` anchored bottom-on-origin. Examples: A plain-walled cap for a PCO 1881 neck: .. pythonscad-example:: from pybosl2.parts.bottlecaps import BottleCaps BottleCaps.pco1881_cap(fa=6).show() """ return _build_cap(_PCO1881, wall, texture, fn=fn, fa=fa, fs=fs)