first commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user