Guest User

Untitled

a guest
Oct 8th, 2026
13
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 35.22 KB | Source Code | 0 0
  1. #!/usr/bin/env python3
  2. """Two printable solids that seat together: a hollow cylinder and a disc-cone.
  3.  
  4. The cylinder is a 50 mm tube. Its outer diameter is 72 mm and the wall is
  5. 1.5 mm, so the bore is 69 mm. The bottom edge is broken by evenly spaced
  6. half-arch cutouts. A requested 2 mm gap between those arches does not divide
  7. the circumference into a whole number of arches, so the count is whichever
  8. leaves a mid-wall gap closest to 2 mm.
  9.  
  10. The second solid is a 100 mm disc, 2 mm thick, with an exponential cone on
  11. top. The cone narrows from the disc diameter to 5 mm over 45 mm, then a 5 mm
  12. cone spike tapers to a point. Where that cone is just inside the cylinder
  13. bore, 1.5 mm deep cutouts accept the solid feet left between the arches.
  14. Each mating face of a cutout is 0.1 mm outside the foot.
  15.  
  16. Run this file to write both STL files next to the script.
  17. """
  18.  
  19. from __future__ import annotations
  20.  
  21. import math
  22. from pathlib import Path
  23.  
  24. import cadquery as cq
  25. from OCP.BRepClass3d import BRepClass3d_SolidClassifier
  26. from OCP.TopAbs import TopAbs_IN
  27. from OCP.gp import gp_Pnt
  28.  
  29. # --- Hollow cylinder dimensions, millimetres ---
  30.  
  31. # Axial length of the tube. The bottom face is z = 0 and the top face is this height.
  32. CYLINDER_HEIGHT_MILLIMETERS = 50.0
  33. # Driving diameter. The bore is this minus twice the wall.
  34. CYLINDER_OUTER_DIAMETER_MILLIMETERS = 72.0
  35. CYLINDER_WALL_THICKNESS_MILLIMETERS = 1.5
  36. CYLINDER_INNER_DIAMETER_MILLIMETERS = (
  37.     CYLINDER_OUTER_DIAMETER_MILLIMETERS - 2.0 * CYLINDER_WALL_THICKNESS_MILLIMETERS
  38. )
  39. # Radius of each half-arch cut up from the bottom edge. The opening width is twice this.
  40. ARCH_CUTOUT_RADIUS_MILLIMETERS = 15.0
  41. # Target clear arc, measured at mid-wall, between neighbouring arch openings.
  42. REQUESTED_ARCH_GAP_MILLIMETERS = 2.0
  43.  
  44. CYLINDER_INNER_RADIUS_MILLIMETERS = CYLINDER_INNER_DIAMETER_MILLIMETERS / 2.0
  45. CYLINDER_OUTER_RADIUS_MILLIMETERS = CYLINDER_OUTER_DIAMETER_MILLIMETERS / 2.0
  46. CYLINDER_MIDWALL_RADIUS_MILLIMETERS = (
  47.     CYLINDER_INNER_RADIUS_MILLIMETERS + CYLINDER_OUTER_RADIUS_MILLIMETERS
  48. ) / 2.0
  49.  
  50. # How far the arch cutter extends past each face of the wall, so the boolean
  51. # goes all the way through instead of leaving a skin.
  52. ARCH_CUTTER_AXIAL_OVERTRAVEL_MILLIMETERS = 6.0
  53.  
  54. # --- Disc, exponential cone, and spike ---
  55.  
  56. DISC_OUTER_DIAMETER_MILLIMETERS = 100.0
  57. DISC_THICKNESS_MILLIMETERS = 2.0
  58. # Height of the narrowing cone plus the spike, not including the disc.
  59. CONE_AND_SPIKE_STACK_HEIGHT_MILLIMETERS = 50.0
  60. SPIKE_BASE_DIAMETER_MILLIMETERS = 5.0
  61. SPIKE_HEIGHT_MILLIMETERS = 5.0
  62. # The narrowing section is the stack minus the spike: 50 mm - 5 mm.
  63. NARROWING_CONE_HEIGHT_MILLIMETERS = (
  64.     CONE_AND_SPIKE_STACK_HEIGHT_MILLIMETERS - SPIKE_HEIGHT_MILLIMETERS
  65. )
  66. # The cone ends at the spike's base diameter, then the spike continues to a point.
  67. NARROWING_CONE_END_DIAMETER_MILLIMETERS = SPIKE_BASE_DIAMETER_MILLIMETERS
  68.  
  69. DISC_OUTER_RADIUS_MILLIMETERS = DISC_OUTER_DIAMETER_MILLIMETERS / 2.0
  70. NARROWING_CONE_END_RADIUS_MILLIMETERS = NARROWING_CONE_END_DIAMETER_MILLIMETERS / 2.0
  71. DISC_CONE_OVERALL_HEIGHT_MILLIMETERS = (
  72.     DISC_THICKNESS_MILLIMETERS + NARROWING_CONE_HEIGHT_MILLIMETERS + SPIKE_HEIGHT_MILLIMETERS
  73. )
  74.  
  75. # Radius along the cone is DISC_OUTER_RADIUS * exp(decay * height / cone_height).
  76. # decay is negative, so the radius shrinks from the disc to the 5 mm end.
  77. EXPONENTIAL_CONE_DECAY_CONSTANT = math.log(
  78.     NARROWING_CONE_END_RADIUS_MILLIMETERS / DISC_OUTER_RADIUS_MILLIMETERS
  79. )
  80. # Straight segments used to revolve the exponential. The solid's surface is the
  81. # chord between these samples, which sits slightly outside the true curve.
  82. EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT = 96
  83.  
  84. # How far each arch-gap foot sinks into the cone when the cylinder is seated.
  85. JOIN_CUTOUT_DEPTH_MILLIMETERS = 1.5
  86. # Extra space on every mating face of a cutout so the foot can enter.
  87. JOIN_FIT_CLEARANCE_MILLIMETERS = 0.1
  88.  
  89. # --- Output files ---
  90.  
  91. HOLLOW_CYLINDER_STL_FILENAME = "hollow_cylinder.stl"
  92. DISC_CONE_STL_FILENAME = "disc_cone.stl"
  93. HOLLOW_CYLINDER_STL_PATH = Path(__file__).with_name(HOLLOW_CYLINDER_STL_FILENAME)
  94. DISC_CONE_STL_PATH = Path(__file__).with_name(DISC_CONE_STL_FILENAME)
  95.  
  96. # Mesh tolerances passed to the STL exporter.
  97. STL_LINEAR_DEFLECTION_MILLIMETERS = 0.02
  98. STL_ANGULAR_DEFLECTION_RADIANS = 0.05
  99.  
  100. # Arch-count search. Gaps smaller than the minimum are treated as closed.
  101. MINIMUM_ARCH_COUNT_TO_TRY = 3
  102. MAXIMUM_ARCH_COUNT_TO_TRY = 59
  103. MINIMUM_OPEN_ARCH_GAP_MILLIMETERS = 0.2
  104.  
  105. # Checks that the generated solids match the dimensions above.
  106. BOUNDING_BOX_TOLERANCE_MILLIMETERS = 0.05
  107. POINT_CLASSIFIER_TOLERANCE_MILLIMETERS = 1e-7
  108. # Touching seat faces can leave a hair of intersection volume. Anything larger is a clash.
  109. MAXIMUM_SEATED_OVERLAP_VOLUME_CUBIC_MILLIMETERS = 0.5
  110.  
  111.  
  112. def clear_arch_gap_arc_length_millimeters(radius_millimeters: float, arch_count: int) -> float:
  113.     """Uncut arc between neighbouring arch openings, measured at radius_millimeters.
  114.  
  115.    Each arch is a radial cutter, so the opening's half-angle at a given
  116.    radius is asin(arch_radius / radius). The gap is whatever is left of the
  117.    equal angular pitch after that opening is removed.
  118.    """
  119.     angular_pitch_radians = 2.0 * math.pi / arch_count
  120.     arch_opening_radians = 2.0 * math.asin(ARCH_CUTOUT_RADIUS_MILLIMETERS / radius_millimeters)
  121.     return radius_millimeters * (angular_pitch_radians - arch_opening_radians)
  122.  
  123.  
  124. def choose_arch_count() -> int:
  125.     """Pick the arch count whose mid-wall gap is closest to the requested 2 mm.
  126.  
  127.    The circumference is not an integer multiple of (opening + requested gap),
  128.    so the arches are spaced evenly and the gap comes out a little off 2 mm.
  129.    """
  130.     closest_gap_error_and_count: tuple[float, int] | None = None
  131.     for candidate_arch_count in range(MINIMUM_ARCH_COUNT_TO_TRY, MAXIMUM_ARCH_COUNT_TO_TRY + 1):
  132.         midwall_gap_millimeters = clear_arch_gap_arc_length_millimeters(
  133.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS,
  134.             candidate_arch_count,
  135.         )
  136.         if midwall_gap_millimeters <= MINIMUM_OPEN_ARCH_GAP_MILLIMETERS:
  137.             continue
  138.         gap_error_millimeters = abs(midwall_gap_millimeters - REQUESTED_ARCH_GAP_MILLIMETERS)
  139.         if (
  140.             closest_gap_error_and_count is None
  141.             or gap_error_millimeters < closest_gap_error_and_count[0]
  142.         ):
  143.             closest_gap_error_and_count = (gap_error_millimeters, candidate_arch_count)
  144.     if closest_gap_error_and_count is None:
  145.         raise RuntimeError("no arch count leaves a positive gap")
  146.     return closest_gap_error_and_count[1]
  147.  
  148.  
  149. def make_hollow_cylinder_wall() -> cq.Workplane:
  150.     """Open tube before the arch cutouts. Outer circle first, then the bore."""
  151.     return (
  152.         cq.Workplane("XY")
  153.         .circle(CYLINDER_OUTER_RADIUS_MILLIMETERS)
  154.         .circle(CYLINDER_INNER_RADIUS_MILLIMETERS)
  155.         .extrude(CYLINDER_HEIGHT_MILLIMETERS)
  156.     )
  157.  
  158.  
  159. def make_radial_arch_cutter(
  160.     arch_index: int,
  161.     arch_count: int,
  162.     cutter_radius_millimeters: float,
  163.     cutter_axis_height_millimeters: float,
  164.     radial_start_millimeters: float,
  165.     radial_end_millimeters: float,
  166. ) -> cq.Solid:
  167.     """Horizontal cylinder lying on a radius, used as a half-arch cutter.
  168.  
  169.    The circle is drawn in the YZ plane and extruded along X, then rotated
  170.    about Z to this arch's station. The axis sits on cutter_axis_height, so
  171.    only the upper half meets a solid that begins at that height. That upper
  172.    half is the half-arch.
  173.    """
  174.     arch_station_angle_degrees = 360.0 * arch_index / arch_count
  175.     cutter_length_millimeters = radial_end_millimeters - radial_start_millimeters
  176.     cutter_center_radius_millimeters = (radial_start_millimeters + radial_end_millimeters) / 2.0
  177.     radial_cutter = (
  178.         cq.Workplane("YZ")
  179.         .circle(cutter_radius_millimeters)
  180.         .extrude(cutter_length_millimeters / 2.0, both=True)
  181.         .translate((cutter_center_radius_millimeters, 0.0, cutter_axis_height_millimeters))
  182.         .rotate((0, 0, 0), (0, 0, 1), arch_station_angle_degrees)
  183.     )
  184.     return radial_cutter.val()
  185.  
  186.  
  187. def make_cylinder_arch_cutter(arch_index: int, arch_count: int) -> cq.Solid:
  188.     """Arch cutter for the cylinder wall, centered on the bottom plane (z = 0)."""
  189.     radial_overtravel_millimeters = (
  190.         CYLINDER_WALL_THICKNESS_MILLIMETERS + ARCH_CUTTER_AXIAL_OVERTRAVEL_MILLIMETERS
  191.     ) / 2.0
  192.     return make_radial_arch_cutter(
  193.         arch_index,
  194.         arch_count,
  195.         ARCH_CUTOUT_RADIUS_MILLIMETERS,
  196.         0.0,
  197.         CYLINDER_MIDWALL_RADIUS_MILLIMETERS - radial_overtravel_millimeters,
  198.         CYLINDER_MIDWALL_RADIUS_MILLIMETERS + radial_overtravel_millimeters,
  199.     )
  200.  
  201.  
  202. def build_hollow_cylinder() -> tuple[cq.Workplane, int]:
  203.     """Wall with every half-arch removed. Returns the solid and the arch count."""
  204.     arch_count = choose_arch_count()
  205.     hollow_cylinder_wall = make_hollow_cylinder_wall()
  206.     arch_cutters = cq.Compound.makeCompound(
  207.         [make_cylinder_arch_cutter(arch_index, arch_count) for arch_index in range(arch_count)]
  208.     )
  209.     return hollow_cylinder_wall.cut(arch_cutters), arch_count
  210.  
  211.  
  212. def point_is_inside_solid(
  213.     solid_shape: cq.Shape,
  214.     x_millimeters: float,
  215.     y_millimeters: float,
  216.     z_millimeters: float,
  217. ) -> bool:
  218.     """True when the sample point is strictly inside the solid, not on its skin."""
  219.     solid_classifier = BRepClass3d_SolidClassifier(
  220.         solid_shape.wrapped,
  221.         gp_Pnt(x_millimeters, y_millimeters, z_millimeters),
  222.         POINT_CLASSIFIER_TOLERANCE_MILLIMETERS,
  223.     )
  224.     return solid_classifier.State() == TopAbs_IN
  225.  
  226.  
  227. def verify_hollow_cylinder(hollow_cylinder: cq.Workplane, arch_count: int) -> None:
  228.     """Check the tube bounds, the bore, and that every arch actually cuts the wall."""
  229.     hollow_cylinder_shape = hollow_cylinder.val()
  230.     if not hollow_cylinder_shape.isValid():
  231.         raise SystemExit("hollow cylinder solid is not valid")
  232.  
  233.     bounding_box = hollow_cylinder_shape.BoundingBox()
  234.     expected_axis_limits_millimeters = {
  235.         "x": (bounding_box.xmin, bounding_box.xmax, -CYLINDER_OUTER_RADIUS_MILLIMETERS, CYLINDER_OUTER_RADIUS_MILLIMETERS),
  236.         "y": (bounding_box.ymin, bounding_box.ymax, -CYLINDER_OUTER_RADIUS_MILLIMETERS, CYLINDER_OUTER_RADIUS_MILLIMETERS),
  237.         "z": (bounding_box.zmin, bounding_box.zmax, 0.0, CYLINDER_HEIGHT_MILLIMETERS),
  238.     }
  239.     for axis_name, (
  240.         actual_low_millimeters,
  241.         actual_high_millimeters,
  242.         expected_low_millimeters,
  243.         expected_high_millimeters,
  244.     ) in expected_axis_limits_millimeters.items():
  245.         low_error_millimeters = abs(actual_low_millimeters - expected_low_millimeters)
  246.         high_error_millimeters = abs(actual_high_millimeters - expected_high_millimeters)
  247.         if (
  248.             low_error_millimeters > BOUNDING_BOX_TOLERANCE_MILLIMETERS
  249.             or high_error_millimeters > BOUNDING_BOX_TOLERANCE_MILLIMETERS
  250.         ):
  251.             raise SystemExit(
  252.                 f"hollow cylinder bounding box {axis_name} is "
  253.                 f"{actual_low_millimeters:.3f}..{actual_high_millimeters:.3f}"
  254.             )
  255.  
  256.     # (x, y, z, should_be_inside, what_this_sample_is_checking)
  257.     inside_outside_samples = [
  258.         (CYLINDER_MIDWALL_RADIUS_MILLIMETERS, 0.0, CYLINDER_HEIGHT_MILLIMETERS / 2.0, True, "wall at mid height"),
  259.         (CYLINDER_INNER_RADIUS_MILLIMETERS - 1.0, 0.0, CYLINDER_HEIGHT_MILLIMETERS / 2.0, False, "bore"),
  260.         (CYLINDER_OUTER_RADIUS_MILLIMETERS + 1.0, 0.0, CYLINDER_HEIGHT_MILLIMETERS / 2.0, False, "outside"),
  261.         (CYLINDER_MIDWALL_RADIUS_MILLIMETERS, 0.0, 2.0, False, "center of an arch"),
  262.         (
  263.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS,
  264.             0.0,
  265.             ARCH_CUTOUT_RADIUS_MILLIMETERS + 2.0,
  266.             True,
  267.             "wall above an arch",
  268.         ),
  269.     ]
  270.     # Arch 0 is on +X. The first gap center is halfway to arch 1.
  271.     first_arch_gap_angle_radians = math.pi / arch_count
  272.     inside_outside_samples.append(
  273.         (
  274.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.cos(first_arch_gap_angle_radians),
  275.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.sin(first_arch_gap_angle_radians),
  276.             0.4,
  277.             True,
  278.             "bottom rim between arches",
  279.         )
  280.     )
  281.     for (
  282.         sample_x_millimeters,
  283.         sample_y_millimeters,
  284.         sample_z_millimeters,
  285.         sample_should_be_inside,
  286.         sample_label,
  287.     ) in inside_outside_samples:
  288.         sample_is_inside = point_is_inside_solid(
  289.             hollow_cylinder_shape,
  290.             sample_x_millimeters,
  291.             sample_y_millimeters,
  292.             sample_z_millimeters,
  293.         )
  294.         if sample_is_inside != sample_should_be_inside:
  295.             raise SystemExit(
  296.                 f"{sample_label}: expected inside={sample_should_be_inside}, got {sample_is_inside}"
  297.             )
  298.  
  299.     arches_cut_through_wall_count = 0
  300.     for arch_index in range(arch_count):
  301.         arch_station_angle_radians = 2.0 * math.pi * arch_index / arch_count
  302.         arch_center_x_millimeters = CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.cos(
  303.             arch_station_angle_radians
  304.         )
  305.         arch_center_y_millimeters = CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.sin(
  306.             arch_station_angle_radians
  307.         )
  308.         # Two millimetres up is still inside the half-arch, so the wall must be gone.
  309.         if point_is_inside_solid(
  310.             hollow_cylinder_shape,
  311.             arch_center_x_millimeters,
  312.             arch_center_y_millimeters,
  313.             2.0,
  314.         ):
  315.             raise SystemExit(f"arch {arch_index} did not cut through the wall")
  316.         arches_cut_through_wall_count += 1
  317.     if arches_cut_through_wall_count != arch_count:
  318.         raise SystemExit(f"expected {arch_count} arches, checked {arches_cut_through_wall_count}")
  319.  
  320.  
  321. def exponential_cone_radius_millimeters(height_fraction: float) -> float:
  322.     """Outer radius of the true exponential. 0 is the disc, 1 is the 5 mm end."""
  323.     return DISC_OUTER_RADIUS_MILLIMETERS * math.exp(
  324.         EXPONENTIAL_CONE_DECAY_CONSTANT * height_fraction
  325.     )
  326.  
  327.  
  328. def narrowing_cone_height_at_radius_millimeters(target_radius_millimeters: float) -> float:
  329.     """Height above the disc where the revolved cone surface equals target_radius.
  330.  
  331.    The solid is a polyline, so between samples the surface is a straight chord.
  332.    That chord sits outside the exponential. The seat is measured on the chord,
  333.    which is the surface the cylinder actually touches.
  334.    """
  335.     for segment_index in range(EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT):
  336.         segment_start_height_millimeters = (
  337.             NARROWING_CONE_HEIGHT_MILLIMETERS
  338.             * segment_index
  339.             / EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT
  340.         )
  341.         segment_end_height_millimeters = (
  342.             NARROWING_CONE_HEIGHT_MILLIMETERS
  343.             * (segment_index + 1)
  344.             / EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT
  345.         )
  346.         segment_start_radius_millimeters = exponential_cone_radius_millimeters(
  347.             segment_index / EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT
  348.         )
  349.         segment_end_radius_millimeters = exponential_cone_radius_millimeters(
  350.             (segment_index + 1) / EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT
  351.         )
  352.         radius_is_on_this_segment = (
  353.             segment_end_radius_millimeters
  354.             <= target_radius_millimeters
  355.             <= segment_start_radius_millimeters
  356.         )
  357.         if not radius_is_on_this_segment:
  358.             continue
  359.         radius_span_millimeters = (
  360.             segment_end_radius_millimeters - segment_start_radius_millimeters
  361.         )
  362.         if radius_span_millimeters == 0.0:
  363.             return segment_start_height_millimeters
  364.         segment_fraction = (
  365.             target_radius_millimeters - segment_start_radius_millimeters
  366.         ) / radius_span_millimeters
  367.         return segment_start_height_millimeters + segment_fraction * (
  368.             segment_end_height_millimeters - segment_start_height_millimeters
  369.         )
  370.     raise RuntimeError(f"cone never reaches radius {target_radius_millimeters:.3f} mm")
  371.  
  372.  
  373. def cylinder_seat_height_millimeters() -> float:
  374.     """World Z where the cone surface is one clearance inside the cylinder bore.
  375.  
  376.    Above this height the cone is narrower than the bore, so the tube can
  377.    slide down over it. The arch-gap feet stop 1.5 mm below this height.
  378.    """
  379.     bore_radius_with_clearance_millimeters = (
  380.         CYLINDER_INNER_RADIUS_MILLIMETERS - JOIN_FIT_CLEARANCE_MILLIMETERS
  381.     )
  382.     return DISC_THICKNESS_MILLIMETERS + narrowing_cone_height_at_radius_millimeters(
  383.         bore_radius_with_clearance_millimeters
  384.     )
  385.  
  386.  
  387. def seated_cylinder_bottom_height_millimeters() -> float:
  388.     """World Z of the cylinder's bottom face when the feet are fully in the cutouts."""
  389.     return cylinder_seat_height_millimeters() - JOIN_CUTOUT_DEPTH_MILLIMETERS
  390.  
  391.  
  392. def make_arch_gap_join_cutouts(arch_count: int) -> cq.Workplane:
  393.     """One 1.5 mm pocket per arch gap, sized so the cylinder foot can drop in.
  394.  
  395.    The pocket is the wall annulus, grown by the fit clearance, with the arch
  396.    openings put back using a slightly smaller arch radius. A smaller arch
  397.    leaves a wider foot-shaped pocket, which is the 0.1 mm side clearance.
  398.    The pocket's bottom is the floor the foot lands on.
  399.    """
  400.     cutout_arch_radius_millimeters = (
  401.         ARCH_CUTOUT_RADIUS_MILLIMETERS - JOIN_FIT_CLEARANCE_MILLIMETERS
  402.     )
  403.     cutout_inner_radius_millimeters = (
  404.         CYLINDER_INNER_RADIUS_MILLIMETERS - JOIN_FIT_CLEARANCE_MILLIMETERS
  405.     )
  406.     cutout_outer_radius_millimeters = (
  407.         CYLINDER_OUTER_RADIUS_MILLIMETERS + JOIN_FIT_CLEARANCE_MILLIMETERS
  408.     )
  409.     cutout_floor_height_millimeters = seated_cylinder_bottom_height_millimeters()
  410.     # Annulus standing on the cutout floor. The arch cutters then remove the
  411.     # openings, leaving only the foot-shaped pockets.
  412.     cutout_annulus = (
  413.         cq.Workplane("XY")
  414.         .workplane(offset=cutout_floor_height_millimeters)
  415.         .circle(cutout_outer_radius_millimeters)
  416.         .circle(cutout_inner_radius_millimeters)
  417.         .extrude(JOIN_CUTOUT_DEPTH_MILLIMETERS)
  418.     )
  419.     radial_overtravel_millimeters = (
  420.         CYLINDER_WALL_THICKNESS_MILLIMETERS + ARCH_CUTTER_AXIAL_OVERTRAVEL_MILLIMETERS
  421.     ) / 2.0
  422.     smaller_arch_cutters = [
  423.         make_radial_arch_cutter(
  424.             arch_index,
  425.             arch_count,
  426.             cutout_arch_radius_millimeters,
  427.             cutout_floor_height_millimeters,
  428.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS - radial_overtravel_millimeters,
  429.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS + radial_overtravel_millimeters,
  430.         )
  431.         for arch_index in range(arch_count)
  432.     ]
  433.     return cutout_annulus.cut(cq.Compound.makeCompound(smaller_arch_cutters))
  434.  
  435.  
  436. def make_disc_cone(arch_count: int) -> cq.Workplane:
  437.     """Disc, exponential cone, point spike, then the arch-gap join cutouts.
  438.  
  439.    The profile is drawn in the XZ plane and revolved about Z. It starts at the
  440.    disc's bottom center, walks out around the disc, follows the exponential
  441.    down to 5 mm, and closes through the spike tip back along the axis.
  442.    """
  443.     disc_cone_profile = (
  444.         cq.Workplane("XZ")
  445.         .moveTo(0, 0)
  446.         .lineTo(DISC_OUTER_RADIUS_MILLIMETERS, 0)
  447.         .lineTo(DISC_OUTER_RADIUS_MILLIMETERS, DISC_THICKNESS_MILLIMETERS)
  448.     )
  449.     for segment_index in range(1, EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT + 1):
  450.         height_fraction = segment_index / EXPONENTIAL_CONE_PROFILE_SEGMENT_COUNT
  451.         profile_radius_millimeters = exponential_cone_radius_millimeters(height_fraction)
  452.         profile_height_millimeters = (
  453.             DISC_THICKNESS_MILLIMETERS + NARROWING_CONE_HEIGHT_MILLIMETERS * height_fraction
  454.         )
  455.         disc_cone_profile = disc_cone_profile.lineTo(
  456.             profile_radius_millimeters,
  457.             profile_height_millimeters,
  458.         )
  459.     revolved_disc_cone = (
  460.         disc_cone_profile.lineTo(0, DISC_CONE_OVERALL_HEIGHT_MILLIMETERS).close().revolve(360)
  461.     )
  462.     return revolved_disc_cone.cut(make_arch_gap_join_cutouts(arch_count))
  463.  
  464.  
  465. def uncut_disc_cone_volume_cubic_millimeters() -> float:
  466.     """Analytic volume of the disc, exponential cone, and spike before cutouts.
  467.  
  468.    The cone term is the integral of pi * r(z)^2 for r(z) = R0 * exp(k * z / H).
  469.    Cutouts remove a little of this, so the finished solid is slightly smaller.
  470.    """
  471.     disc_volume_cubic_millimeters = (
  472.         math.pi
  473.         * DISC_OUTER_RADIUS_MILLIMETERS
  474.         * DISC_OUTER_RADIUS_MILLIMETERS
  475.         * DISC_THICKNESS_MILLIMETERS
  476.     )
  477.     narrowing_cone_volume_cubic_millimeters = (
  478.         math.pi
  479.         * NARROWING_CONE_HEIGHT_MILLIMETERS
  480.         * (
  481.             NARROWING_CONE_END_RADIUS_MILLIMETERS * NARROWING_CONE_END_RADIUS_MILLIMETERS
  482.             - DISC_OUTER_RADIUS_MILLIMETERS * DISC_OUTER_RADIUS_MILLIMETERS
  483.         )
  484.         / (2.0 * EXPONENTIAL_CONE_DECAY_CONSTANT)
  485.     )
  486.     spike_volume_cubic_millimeters = (
  487.         math.pi
  488.         * NARROWING_CONE_END_RADIUS_MILLIMETERS
  489.         * NARROWING_CONE_END_RADIUS_MILLIMETERS
  490.         * SPIKE_HEIGHT_MILLIMETERS
  491.         / 3.0
  492.     )
  493.     return (
  494.         disc_volume_cubic_millimeters
  495.         + narrowing_cone_volume_cubic_millimeters
  496.         + spike_volume_cubic_millimeters
  497.     )
  498.  
  499.  
  500. def verify_cylinder_seats_in_cutouts(
  501.     disc_cone: cq.Workplane,
  502.     hollow_cylinder: cq.Workplane,
  503.     arch_count: int,
  504. ) -> None:
  505.     """Place the cylinder on the cone and check that only the feet occupy cutouts.
  506.  
  507.    Same orientation for both parts: arch 0 is on +X. The cylinder is shifted
  508.    up so its bottom face lies on the cutout floors. A real clash shows up as
  509.    intersection volume. Each gap center must be empty cone and solid cylinder,
  510.    and each arch center must be the reverse so the cone still passes through
  511.    the arch opening.
  512.    """
  513.     disc_cone_shape = disc_cone.val()
  514.     seated_hollow_cylinder = hollow_cylinder.translate(
  515.         (0.0, 0.0, seated_cylinder_bottom_height_millimeters())
  516.     )
  517.     seated_overlap = disc_cone.intersect(seated_hollow_cylinder)
  518.     seated_overlap_volume_cubic_millimeters = sum(
  519.         overlap_solid.Volume() for overlap_solid in seated_overlap.solids().vals()
  520.     )
  521.     if seated_overlap_volume_cubic_millimeters > MAXIMUM_SEATED_OVERLAP_VOLUME_CUBIC_MILLIMETERS:
  522.         raise SystemExit(
  523.             "seated cylinder intersects the cone by "
  524.             f"{seated_overlap_volume_cubic_millimeters:.2f} mm^3"
  525.         )
  526.  
  527.     cutout_floor_height_millimeters = seated_cylinder_bottom_height_millimeters()
  528.     seated_cylinder_shape = seated_hollow_cylinder.val()
  529.     sample_height_inside_cutout_millimeters = (
  530.         cutout_floor_height_millimeters + JOIN_CUTOUT_DEPTH_MILLIMETERS / 2.0
  531.     )
  532.     for arch_index in range(arch_count):
  533.         arch_gap_angle_radians = math.radians(360.0 * (arch_index + 0.5) / arch_count)
  534.         arch_station_angle_radians = math.radians(360.0 * arch_index / arch_count)
  535.         arch_gap_foot_sample = (
  536.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.cos(arch_gap_angle_radians),
  537.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.sin(arch_gap_angle_radians),
  538.             sample_height_inside_cutout_millimeters,
  539.         )
  540.         arch_opening_sample = (
  541.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.cos(arch_station_angle_radians),
  542.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.sin(arch_station_angle_radians),
  543.             sample_height_inside_cutout_millimeters,
  544.         )
  545.         foot_is_inside_cone = point_is_inside_solid(disc_cone_shape, *arch_gap_foot_sample)
  546.         foot_is_inside_cylinder = point_is_inside_solid(
  547.             seated_cylinder_shape, *arch_gap_foot_sample
  548.         )
  549.         if foot_is_inside_cone or not foot_is_inside_cylinder:
  550.             raise SystemExit(f"arch gap {arch_index} does not sit in its cutout")
  551.         arch_opening_is_inside_cone = point_is_inside_solid(disc_cone_shape, *arch_opening_sample)
  552.         arch_opening_is_inside_cylinder = point_is_inside_solid(
  553.             seated_cylinder_shape, *arch_opening_sample
  554.         )
  555.         if not arch_opening_is_inside_cone or arch_opening_is_inside_cylinder:
  556.             raise SystemExit(f"cone does not pass through arch {arch_index}")
  557.         # Just under the pocket the cone must still be solid, so the foot has a floor.
  558.         cutout_floor_sample = (
  559.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.cos(arch_gap_angle_radians),
  560.             CYLINDER_MIDWALL_RADIUS_MILLIMETERS * math.sin(arch_gap_angle_radians),
  561.             cutout_floor_height_millimeters - 0.2,
  562.         )
  563.         if not point_is_inside_solid(disc_cone_shape, *cutout_floor_sample):
  564.             raise SystemExit(f"cutout {arch_index} has no floor for the arch gap to land on")
  565.  
  566.  
  567. def verify_disc_cone(disc_cone: cq.Workplane, arch_count: int) -> None:
  568.     """Check the disc, the exponential taper, the spike, and the join cutouts."""
  569.     disc_cone_shape = disc_cone.val()
  570.     if not disc_cone_shape.isValid():
  571.         raise SystemExit("disc cone solid is not valid")
  572.  
  573.     bounding_box = disc_cone_shape.BoundingBox()
  574.     expected_axis_limits_millimeters = {
  575.         "x": (bounding_box.xmin, bounding_box.xmax, -DISC_OUTER_RADIUS_MILLIMETERS, DISC_OUTER_RADIUS_MILLIMETERS),
  576.         "y": (bounding_box.ymin, bounding_box.ymax, -DISC_OUTER_RADIUS_MILLIMETERS, DISC_OUTER_RADIUS_MILLIMETERS),
  577.         "z": (bounding_box.zmin, bounding_box.zmax, 0.0, DISC_CONE_OVERALL_HEIGHT_MILLIMETERS),
  578.     }
  579.     for axis_name, (
  580.         actual_low_millimeters,
  581.         actual_high_millimeters,
  582.         expected_low_millimeters,
  583.         expected_high_millimeters,
  584.     ) in expected_axis_limits_millimeters.items():
  585.         low_error_millimeters = abs(actual_low_millimeters - expected_low_millimeters)
  586.         high_error_millimeters = abs(actual_high_millimeters - expected_high_millimeters)
  587.         if (
  588.             low_error_millimeters > BOUNDING_BOX_TOLERANCE_MILLIMETERS
  589.             or high_error_millimeters > BOUNDING_BOX_TOLERANCE_MILLIMETERS
  590.         ):
  591.             raise SystemExit(
  592.                 f"disc cone bounding box {axis_name} is "
  593.                 f"{actual_low_millimeters:.3f}..{actual_high_millimeters:.3f}"
  594.             )
  595.  
  596.     narrowing_cone_mid_height_millimeters = (
  597.         DISC_THICKNESS_MILLIMETERS + NARROWING_CONE_HEIGHT_MILLIMETERS / 2.0
  598.     )
  599.     narrowing_cone_mid_radius_millimeters = exponential_cone_radius_millimeters(0.5)
  600.     # A straight taper would still contain this point. The exponential does not.
  601.     straight_taper_mid_radius_millimeters = (
  602.         DISC_OUTER_RADIUS_MILLIMETERS + NARROWING_CONE_END_RADIUS_MILLIMETERS
  603.     ) / 2.0
  604.     spike_mid_height_millimeters = (
  605.         DISC_THICKNESS_MILLIMETERS
  606.         + NARROWING_CONE_HEIGHT_MILLIMETERS
  607.         + SPIKE_HEIGHT_MILLIMETERS / 2.0
  608.     )
  609.     spike_mid_radius_millimeters = NARROWING_CONE_END_RADIUS_MILLIMETERS / 2.0
  610.     inside_outside_samples = [
  611.         (0.0, 0.0, DISC_THICKNESS_MILLIMETERS / 2.0, True, "disc center"),
  612.         (DISC_OUTER_RADIUS_MILLIMETERS - 0.5, 0.0, DISC_THICKNESS_MILLIMETERS / 2.0, True, "disc rim"),
  613.         (DISC_OUTER_RADIUS_MILLIMETERS - 0.5, 0.0, DISC_THICKNESS_MILLIMETERS + 1.0, False, "above the disc rim"),
  614.         (
  615.             narrowing_cone_mid_radius_millimeters - 1.0,
  616.             0.0,
  617.             narrowing_cone_mid_height_millimeters,
  618.             True,
  619.             "inside the cone",
  620.         ),
  621.         (
  622.             narrowing_cone_mid_radius_millimeters + 1.0,
  623.             0.0,
  624.             narrowing_cone_mid_height_millimeters,
  625.             False,
  626.             "outside the cone",
  627.         ),
  628.         (
  629.             straight_taper_mid_radius_millimeters,
  630.             0.0,
  631.             narrowing_cone_mid_height_millimeters,
  632.             False,
  633.             "outside the exponential profile",
  634.         ),
  635.         (
  636.             NARROWING_CONE_END_RADIUS_MILLIMETERS - 0.3,
  637.             0.0,
  638.             DISC_THICKNESS_MILLIMETERS + NARROWING_CONE_HEIGHT_MILLIMETERS - 0.3,
  639.             True,
  640.             "cone top",
  641.         ),
  642.         (spike_mid_radius_millimeters - 0.2, 0.0, spike_mid_height_millimeters, True, "inside the spike"),
  643.         (spike_mid_radius_millimeters + 0.4, 0.0, spike_mid_height_millimeters, False, "outside the spike"),
  644.         (0.0, 0.0, DISC_CONE_OVERALL_HEIGHT_MILLIMETERS + 1.0, False, "above the tip"),
  645.     ]
  646.     cutout_sample_height_millimeters = (
  647.         seated_cylinder_bottom_height_millimeters() + JOIN_CUTOUT_DEPTH_MILLIMETERS / 2.0
  648.     )
  649.     first_arch_gap_angle_radians = math.pi / arch_count
  650.     arch_gap_x_unit = math.cos(first_arch_gap_angle_radians)
  651.     arch_gap_y_unit = math.sin(first_arch_gap_angle_radians)
  652.     inside_outside_samples.extend(
  653.         [
  654.             (
  655.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_x_unit,
  656.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_y_unit,
  657.                 cutout_sample_height_millimeters,
  658.                 False,
  659.                 "join cutout at the cylinder bore",
  660.             ),
  661.             (
  662.                 (CYLINDER_INNER_RADIUS_MILLIMETERS - JOIN_FIT_CLEARANCE_MILLIMETERS - 0.25)
  663.                 * arch_gap_x_unit,
  664.                 (CYLINDER_INNER_RADIUS_MILLIMETERS - JOIN_FIT_CLEARANCE_MILLIMETERS - 0.25)
  665.                 * arch_gap_y_unit,
  666.                 cutout_sample_height_millimeters,
  667.                 True,
  668.                 "inside the cutout's bore side",
  669.             ),
  670.             (
  671.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_x_unit,
  672.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_y_unit,
  673.                 seated_cylinder_bottom_height_millimeters() - 0.2,
  674.                 True,
  675.                 "cutout floor",
  676.             ),
  677.             (
  678.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS,
  679.                 0.0,
  680.                 cutout_sample_height_millimeters,
  681.                 True,
  682.                 "cone remains in the arch opening",
  683.             ),
  684.             (
  685.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_x_unit,
  686.                 CYLINDER_MIDWALL_RADIUS_MILLIMETERS * arch_gap_y_unit,
  687.                 DISC_THICKNESS_MILLIMETERS + 0.75,
  688.                 True,
  689.                 "cone base is no longer notched",
  690.             ),
  691.         ]
  692.     )
  693.     for height_fraction in (0.25, 0.5, 0.75):
  694.         profile_radius_millimeters = exponential_cone_radius_millimeters(height_fraction)
  695.         profile_height_millimeters = (
  696.             DISC_THICKNESS_MILLIMETERS + NARROWING_CONE_HEIGHT_MILLIMETERS * height_fraction
  697.         )
  698.         inside_outside_samples.append(
  699.             (
  700.                 profile_radius_millimeters - 0.4,
  701.                 0.0,
  702.                 profile_height_millimeters,
  703.                 True,
  704.                 f"inside cone at {height_fraction:.2f}",
  705.             )
  706.         )
  707.         inside_outside_samples.append(
  708.             (
  709.                 profile_radius_millimeters + 0.4,
  710.                 0.0,
  711.                 profile_height_millimeters,
  712.                 False,
  713.                 f"outside cone at {height_fraction:.2f}",
  714.             )
  715.         )
  716.     for (
  717.         sample_x_millimeters,
  718.         sample_y_millimeters,
  719.         sample_z_millimeters,
  720.         sample_should_be_inside,
  721.         sample_label,
  722.     ) in inside_outside_samples:
  723.         sample_is_inside = point_is_inside_solid(
  724.             disc_cone_shape,
  725.             sample_x_millimeters,
  726.             sample_y_millimeters,
  727.             sample_z_millimeters,
  728.         )
  729.         if sample_is_inside != sample_should_be_inside:
  730.             raise SystemExit(
  731.                 f"{sample_label}: expected inside={sample_should_be_inside}, got {sample_is_inside}"
  732.             )
  733.  
  734.     uncut_volume_cubic_millimeters = uncut_disc_cone_volume_cubic_millimeters()
  735.     finished_volume_cubic_millimeters = disc_cone_shape.Volume()
  736.     # Cutouts remove material, but only a small fraction of the disc-cone.
  737.     volume_is_plausible = (
  738.         uncut_volume_cubic_millimeters * 0.9
  739.         < finished_volume_cubic_millimeters
  740.         < uncut_volume_cubic_millimeters
  741.     )
  742.     if not volume_is_plausible:
  743.         raise SystemExit(
  744.             f"disc cone volume {finished_volume_cubic_millimeters:.1f} mm^3, "
  745.             f"uncut {uncut_volume_cubic_millimeters:.1f}"
  746.         )
  747.  
  748.  
  749. def export_shape_to_stl(shape: cq.Workplane, stl_path: Path) -> None:
  750.     """Write an STL using the shared linear and angular deflections."""
  751.     cq.exporters.export(
  752.         shape,
  753.         str(stl_path),
  754.         tolerance=STL_LINEAR_DEFLECTION_MILLIMETERS,
  755.         angularTolerance=STL_ANGULAR_DEFLECTION_RADIANS,
  756.     )
  757.  
  758.  
  759. def main() -> None:
  760.     arch_count = choose_arch_count()
  761.     hollow_cylinder, arch_count = build_hollow_cylinder()
  762.     verify_hollow_cylinder(hollow_cylinder, arch_count)
  763.     export_shape_to_stl(hollow_cylinder, HOLLOW_CYLINDER_STL_PATH)
  764.     midwall_arch_gap_millimeters = clear_arch_gap_arc_length_millimeters(
  765.         CYLINDER_MIDWALL_RADIUS_MILLIMETERS,
  766.         arch_count,
  767.     )
  768.     print(f"wrote {HOLLOW_CYLINDER_STL_FILENAME}")
  769.     print(f"outer diameter {CYLINDER_OUTER_DIAMETER_MILLIMETERS:.2f} mm")
  770.     print(f"inner diameter {CYLINDER_INNER_DIAMETER_MILLIMETERS:.2f} mm")
  771.     print(f"height {CYLINDER_HEIGHT_MILLIMETERS:.2f} mm")
  772.     print(f"wall {CYLINDER_WALL_THICKNESS_MILLIMETERS:.2f} mm")
  773.     print(f"arches {arch_count}, radius {ARCH_CUTOUT_RADIUS_MILLIMETERS:.2f} mm")
  774.     print(
  775.         f"gap at mid-wall {midwall_arch_gap_millimeters:.3f} mm "
  776.         f"(requested {REQUESTED_ARCH_GAP_MILLIMETERS:.2f} mm)"
  777.     )
  778.     print(
  779.         "gap at inner surface "
  780.         f"{clear_arch_gap_arc_length_millimeters(CYLINDER_INNER_RADIUS_MILLIMETERS, arch_count):.3f} mm"
  781.     )
  782.     print(
  783.         "gap at outer surface "
  784.         f"{clear_arch_gap_arc_length_millimeters(CYLINDER_OUTER_RADIUS_MILLIMETERS, arch_count):.3f} mm"
  785.     )
  786.     print(f"volume {hollow_cylinder.val().Volume():.1f} mm^3")
  787.  
  788.     disc_cone = make_disc_cone(arch_count)
  789.     verify_disc_cone(disc_cone, arch_count)
  790.     verify_cylinder_seats_in_cutouts(disc_cone, hollow_cylinder, arch_count)
  791.     export_shape_to_stl(disc_cone, DISC_CONE_STL_PATH)
  792.     print(f"wrote {DISC_CONE_STL_FILENAME}")
  793.     print(
  794.         f"disc diameter {DISC_OUTER_DIAMETER_MILLIMETERS:.2f} mm, "
  795.         f"thickness {DISC_THICKNESS_MILLIMETERS:.2f} mm"
  796.     )
  797.     print(
  798.         f"exponential cone height {NARROWING_CONE_HEIGHT_MILLIMETERS:.2f} mm, "
  799.         f"end diameter {NARROWING_CONE_END_DIAMETER_MILLIMETERS:.2f} mm"
  800.     )
  801.     print(
  802.         "diameter at mid-cone "
  803.         f"{2.0 * exponential_cone_radius_millimeters(0.5):.2f} mm"
  804.     )
  805.     print(
  806.         f"spike height {SPIKE_HEIGHT_MILLIMETERS:.2f} mm, "
  807.         f"base diameter {SPIKE_BASE_DIAMETER_MILLIMETERS:.2f} mm"
  808.     )
  809.     cutout_floor_height_above_disc_millimeters = (
  810.         seated_cylinder_bottom_height_millimeters() - DISC_THICKNESS_MILLIMETERS
  811.     )
  812.     print(
  813.         f"join cutouts {arch_count} for bore {CYLINDER_INNER_DIAMETER_MILLIMETERS:.2f} mm, "
  814.         f"{cutout_floor_height_above_disc_millimeters:.2f} mm above the disc"
  815.     )
  816.     print(
  817.         f"cutout depth {JOIN_CUTOUT_DEPTH_MILLIMETERS:.2f} mm, "
  818.         f"clearance {JOIN_FIT_CLEARANCE_MILLIMETERS:.2f} mm per side"
  819.     )
  820.     print(f"overall height {DISC_CONE_OVERALL_HEIGHT_MILLIMETERS:.2f} mm")
  821.     print(f"volume {disc_cone.val().Volume():.1f} mm^3")
  822.  
  823.  
  824. if __name__ == "__main__":
  825.     main()
  826.  
Tags: 3d_model
Add Comment
Please, Sign In to add comment