193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Tidy parametric gear-like model JSON example.
|
|
|
|
This example is intentionally lightweight enough for automated tests. It is not a
|
|
full involute gear generator; it demonstrates the same release-critical behavior:
|
|
expression parameters, derived numeric construction values, canonical graph
|
|
export, and exactly one explicit `leaf_ids` output.
|
|
|
|
Run from the repository root with:
|
|
uv run python examples/06_parametric_gear_model.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import simplecadapi as scad
|
|
|
|
|
|
def _involute_spur_profile_points(
|
|
*,
|
|
tooth_count: int,
|
|
module: float,
|
|
pressure_angle_deg: float = 20.0,
|
|
backlash: float = 0.03,
|
|
profile_points: int = 10,
|
|
root_arc_points: int = 4,
|
|
tip_arc_points: int = 4,
|
|
) -> list[tuple[float, float, float]]:
|
|
"""Sample a closed 2D involute spur gear outline in the XY plane."""
|
|
|
|
import math
|
|
|
|
if tooth_count < 8:
|
|
raise ValueError("tooth_count must be >= 8")
|
|
if module <= 0:
|
|
raise ValueError("module must be > 0")
|
|
|
|
pressure_angle = math.radians(pressure_angle_deg)
|
|
pitch_radius = 0.5 * module * tooth_count
|
|
tip_radius = pitch_radius + module
|
|
root_radius = pitch_radius - 1.25 * module
|
|
base_radius = pitch_radius * math.cos(pressure_angle)
|
|
if root_radius <= 0:
|
|
raise ValueError("invalid gear dimensions: root radius <= 0")
|
|
|
|
half_tooth_angle = (math.pi / (2.0 * tooth_count)) - (
|
|
backlash / (2.0 * pitch_radius)
|
|
)
|
|
inv_pitch = math.tan(pressure_angle) - pressure_angle
|
|
r_start = max(root_radius, base_radius)
|
|
|
|
def flank_angle_at_radius(radius: float) -> float:
|
|
ratio = min(1.0, max(0.0, base_radius / radius))
|
|
phi = math.acos(ratio)
|
|
inv_r = math.tan(phi) - phi
|
|
return half_tooth_angle + inv_pitch - inv_r
|
|
|
|
radii = [
|
|
r_start + (tip_radius - r_start) * i / (profile_points - 1)
|
|
for i in range(profile_points)
|
|
]
|
|
|
|
flank_pos: list[tuple[float, float]] = []
|
|
flank_neg: list[tuple[float, float]] = []
|
|
for radius in radii:
|
|
beta = flank_angle_at_radius(radius)
|
|
x = radius * math.cos(beta)
|
|
y = radius * math.sin(beta)
|
|
flank_pos.append((x, y))
|
|
flank_neg.append((x, -y))
|
|
|
|
start_angle = math.atan2(flank_pos[0][1], flank_pos[0][0])
|
|
tip_angle_neg = math.atan2(flank_neg[-1][1], flank_neg[-1][0])
|
|
tip_angle_pos = math.atan2(flank_pos[-1][1], flank_pos[-1][0])
|
|
|
|
tip_arc = [
|
|
(
|
|
tip_radius
|
|
* math.cos(
|
|
tip_angle_neg + (tip_angle_pos - tip_angle_neg) * i / tip_arc_points
|
|
),
|
|
tip_radius
|
|
* math.sin(
|
|
tip_angle_neg + (tip_angle_pos - tip_angle_neg) * i / tip_arc_points
|
|
),
|
|
)
|
|
for i in range(1, tip_arc_points)
|
|
]
|
|
|
|
tooth_local: list[tuple[float, float]] = []
|
|
tooth_local.append((root_radius * math.cos(-start_angle), root_radius * math.sin(-start_angle)))
|
|
tooth_local.extend(flank_neg)
|
|
tooth_local.extend(tip_arc)
|
|
tooth_local.extend(reversed(flank_pos))
|
|
tooth_local.append((root_radius * math.cos(start_angle), root_radius * math.sin(start_angle)))
|
|
|
|
def rotate_xy(point: tuple[float, float], angle: float) -> tuple[float, float]:
|
|
c = math.cos(angle)
|
|
s = math.sin(angle)
|
|
return (point[0] * c - point[1] * s, point[0] * s + point[1] * c)
|
|
|
|
tooth_pitch_angle = 2.0 * math.pi / tooth_count
|
|
outline: list[tuple[float, float]] = []
|
|
for k in range(tooth_count):
|
|
center_angle = k * tooth_pitch_angle
|
|
tooth_world = [rotate_xy(point, center_angle) for point in tooth_local]
|
|
outline.extend(tooth_world if not outline else tooth_world[1:])
|
|
|
|
a0 = center_angle + start_angle
|
|
a1 = center_angle + tooth_pitch_angle - start_angle
|
|
for i in range(1, root_arc_points):
|
|
angle = a0 + (a1 - a0) * i / root_arc_points
|
|
outline.append((root_radius * math.cos(angle), root_radius * math.sin(angle)))
|
|
|
|
cleaned: list[tuple[float, float]] = []
|
|
for point in outline:
|
|
if not cleaned:
|
|
cleaned.append(point)
|
|
continue
|
|
if math.hypot(point[0] - cleaned[-1][0], point[1] - cleaned[-1][1]) > 1e-7:
|
|
cleaned.append(point)
|
|
|
|
return [(x, y, 0.0) for x, y in cleaned]
|
|
|
|
|
|
def build_model(output_dir: Path) -> dict:
|
|
"""Build a small replayable involute spur gear and write model JSON.
|
|
|
|
Args:
|
|
output_dir: Directory that receives `parametric_gear.model.json`.
|
|
|
|
Returns:
|
|
The parsed exported model payload.
|
|
"""
|
|
|
|
tooth_count_value = 14
|
|
module_value = 1.4
|
|
thickness = scad.var("thickness", 4.0)
|
|
bore_radius = scad.var("bore_radius", 2.2)
|
|
|
|
# Keep derived construction facts as numerics; this example verifies they do
|
|
# not become top-level model variables such as `pitch_radius`.
|
|
profile_points = _involute_spur_profile_points(
|
|
tooth_count=tooth_count_value,
|
|
module=module_value,
|
|
)
|
|
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_polyline_rwire(profile_points, closed=True)
|
|
gear = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), thickness)
|
|
bore = scad.make_cylinder_rsolid(
|
|
bore_radius,
|
|
thickness + 2.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
)
|
|
gear = scad.cut_rsolid(gear, bore)
|
|
# Keep the final output as a single explicit leaf node.
|
|
gear = scad.translate_shape(gear, (0.0, 0.0, 0.0))
|
|
|
|
model_json = scad.export_model_json(session)
|
|
payload = json.loads(model_json)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
(output_dir / "parametric_gear.model.json").write_text(model_json, encoding="utf-8")
|
|
return payload
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
default=Path("examples/out/parametric_gear"),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
payload = build_model(args.output_dir)
|
|
var_names = [
|
|
node.get("name")
|
|
for node in payload["expression_graph"]["nodes"]
|
|
if node.get("kind") == "var"
|
|
]
|
|
|
|
print("leaf_count", len(payload["leaf_ids"]))
|
|
print("graph_nodes", len(payload["graph"]["nodes"]))
|
|
print("vars", ",".join(str(name) for name in var_names))
|
|
print("wrote", args.output_dir / "parametric_gear.model.json")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|