first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
out/
*.step
*.stl
*.json
*.FCStd
*.FCBak
@@ -0,0 +1,31 @@
"""Basic shape-first modeling with the functional API.
Run from the repository root with:
uv run python examples/01_basic_modeling.py
"""
from pathlib import Path
import simplecadapi as scad
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
base = scad.make_box_rsolid(60.0, 36.0, 8.0, bottom_face_center=(0.0, 0.0, 0.0))
hole = scad.make_cylinder_rsolid(5.0, 14.0, bottom_face_center=(0.0, 0.0, -3.0))
slot = scad.make_box_rsolid(18.0, 8.0, 14.0, bottom_face_center=(14.0, 0.0, -3.0))
part = scad.cut_rsolid(base, hole, slot)
boss = scad.make_cylinder_rsolid(8.0, 7.0, bottom_face_center=(-18.0, 0.0, 8.0))
part = scad.union_rsolid(part, boss)
part.auto_tag_faces("box")
print("volume", round(part.get_volume(), 3))
print("faces", len(part.get_faces()))
print("edges", len(part.get_edges()))
scad.export_step(part, str(OUT / "basic_modeling.step"))
scad.export_stl(part, str(OUT / "basic_modeling.stl"))
print("wrote", OUT / "basic_modeling.step")
@@ -0,0 +1,39 @@
"""Record a replayable model graph, export model JSON, then replay it.
Run from the repository root with:
uv run python examples/02_graph_replay.py
"""
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql as Q
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
with scad.GraphSession() as session:
body = scad.make_box_rsolid(40.0, 24.0, 10.0, bottom_face_center=(0.0, 0.0, 0.0))
cutter = scad.make_cylinder_rsolid(4.0, 16.0, bottom_face_center=(0.0, 0.0, -3.0))
drilled = scad.cut_rsolid(body, cutter)
# Use a serializable QL selector instead of relying on OCC edge iteration order.
bottom_circle = (
Q.edges()
.where(Q.curve_type("circle"))
.order_by(Q.center_axis("z"))
.take(1)
.exactly(1)
)
final = scad.chamfer_rsolid(drilled, bottom_circle, 0.6)
model_json = scad.export_model_json(session)
(OUT / "graph_replay.model.json").write_text(model_json, encoding="utf-8")
rebuilt = scad.replay_model_json(model_json)
print("recorded_nodes", session.graph.node_count)
print("replayed_outputs", len(rebuilt))
print("replayed_type", type(rebuilt[0]).__name__ if rebuilt else "none")
print("wrote", OUT / "graph_replay.model.json")
@@ -0,0 +1,33 @@
"""Expression parameters inside a replayable graph/model workflow.
Run from the repository root with:
uv run python examples/03_expressions.py
"""
import json
from pathlib import Path
import simplecadapi as scad
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
width = scad.var("width", 24.0, comment="plate width")
height = scad.var("height", 12.0, comment="plate height")
thickness = scad.var("thickness", 4.0, comment="plate thickness")
with scad.GraphSession() as session:
plate = scad.make_box_rsolid(width, height, thickness)
rib = scad.make_box_rsolid(width / 4.0, height, thickness * 2.0)
rib = scad.translate_shape(rib, (0.0, 0.0, 4.0))
part = scad.union_rsolid(plate, rib)
model_json = scad.export_model_json(session)
payload = json.loads(model_json)
(OUT / "expressions.model.json").write_text(model_json, encoding="utf-8")
print("expression_nodes", len(payload["expression_graph"]["nodes"]))
print("graph_nodes", len(payload["graph"]["nodes"]))
print("volume", round(part.get_volume(), 3))
@@ -0,0 +1,35 @@
"""Profile operations: revolve, loft, and sweep.
Run from the repository root with:
uv run python examples/05_loft_sweep_revolve.py
"""
from pathlib import Path
import simplecadapi as scad
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
# Revolve a closed profile into a small knob.
profile = scad.make_polyline_rwire(
[(0.0, 0.0, 0.0), (4.0, 0.0, 0.0), (3.0, 0.0, 8.0), (1.0, 0.0, 8.0)],
closed=True,
)
knob = scad.revolve_rsolid(profile, axis=(0.0, 0.0, 1.0), angle=360.0)
# Loft between rectangular sections.
a = scad.make_rectangle_rwire(8.0, 8.0, center=(16.0, 0.0, 0.0))
b = scad.make_rectangle_rwire(4.0, 4.0, center=(16.0, 0.0, 8.0))
loft = scad.loft_rsolid([a, b])
# Sweep a circular face along a polyline path.
profile_face = scad.make_circle_rface((30.0, 0.0, 0.0), 1.0, normal=(1.0, 0.0, 0.0))
path = scad.make_polyline_rwire([(30.0, 0.0, 0.0), (34.0, 0.0, 3.0), (38.0, 3.0, 6.0)])
swept = scad.sweep_rsolid(profile_face, path)
scad.export_step([knob, loft, swept], str(OUT / "profile_operations.step"))
print("knob", round(knob.get_volume(), 3))
print("loft", round(loft.get_volume(), 3))
print("swept", round(swept.get_volume(), 3))
@@ -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()
@@ -0,0 +1,312 @@
"""Show how source code maps to the serializable operation tree.
Run from the repository root with:
uv run python examples/07_serialization_operation_tree.py
This example intentionally keeps the geometry simple. Its main purpose is to
show that user-facing calls such as `make_box_rsolid()` and
`helical_sweep_rsolid()` are lowered into the canonical, replayable operation
nodes stored in `model.json`.
Generated files:
examples/out/serialization_operation_tree.model.json
examples/out/serialization_operation_tree.summary.md
examples/out/serialization_operation_tree.step
"""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from textwrap import dedent
import simplecadapi as scad
from simplecadapi import ql as Q
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
MODEL_JSON_PATH = OUT / "serialization_operation_tree.model.json"
SUMMARY_PATH = OUT / "serialization_operation_tree.summary.md"
STEP_PATH = OUT / "serialization_operation_tree.step"
def source_step(name: str):
"""Print a readable marker while building the recorded model."""
print(f"SOURCE STEP: {name}")
# ---------------------------------------------------------------------------
# Expression parameters: model JSON stores numeric snapshots in node.params and
# expression references in node.param_exprs / expression_graph.
# ---------------------------------------------------------------------------
plate_w = scad.var("plate_w", 36.0, comment="main plate width")
plate_h = scad.var("plate_h", 18.0, comment="main plate height")
plate_t = scad.var("plate_t", 3.0, comment="main plate thickness")
hole_r = scad.var("hole_r", 2.2, comment="through-hole radius")
rib_t = scad.var("rib_t", 1.6, comment="rib thickness")
fillet_r = scad.var("fillet_r", 0.45, comment="small edge fillet radius")
with scad.GraphSession() as session:
# Basic construction and primitive lowering:
# make_box_rsolid -> rectangle face -> four line edges -> wire -> face -> extrude
source_step("01 make_box_rsolid(expr dimensions) -> lowered profile + extrude")
plate = scad.make_box_rsolid(plate_w, plate_h, plate_t)
plate = scad.apply_tag(plate, "demo.main_plate")
# make_cylinder_rsolid is also serializable via lowering:
# circle edge -> wire -> face -> extrude
source_step("02 make_cylinder_rsolid(expr radius) -> lowered circle face + extrude")
hole = scad.make_cylinder_rsolid(
hole_r,
plate_t + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
)
drilled_plate = scad.cut_rsolid(plate, hole)
# Core wire/profile API: point, line, circle, arc, spline, helix, wire construction,
# face construction. These are kept small and placed away from the plate so
# they are easy to inspect in the graph without making the shape complicated.
source_step("03 make_point_rvertex")
marker_point = scad.make_point_rvertex(-18.0, -9.0, 6.0)
source_step("04 explicit edges + make_wire_from_edges_rwire + make_face_from_wire_rface")
e1 = scad.make_line_redge((-8.0, 0.0, plate_t), (-6.0, 0.0, plate_t))
e2 = scad.make_three_point_arc_redge(
(-6.0, 0.0, plate_t), (-5.0, 1.0, plate_t), (-4.0, 0.0, plate_t)
)
e3 = scad.make_angle_arc_redge(
(-3.0, 0.0, plate_t), 1.0, 3.14159, 0.0, normal=(0.0, 0.0, 1.0)
)
spline_fit = scad.fit_cubic_bspline_control_points(
[(-2.0, 0.0, plate_t), (-1.0, 0.8, plate_t), (0.0, 0.0, plate_t)],
tolerance=0.01,
)
e4 = scad.make_spline_redge(
control_points=spline_fit.control_points,
knots=spline_fit.unique_knots,
multiplicities=spline_fit.multiplicities,
)
# The four edges above are intentionally separate leaf examples. A valid
# `make_wire_from_edges_rwire` example follows with a closed triangle.
# A closed profile built explicitly from lines, then converted to a face.
tri_a = scad.make_line_redge((8.0, -2.0, plate_t), (11.0, -2.0, plate_t))
tri_b = scad.make_line_redge((11.0, -2.0, plate_t), (9.5, 1.0, plate_t))
tri_c = scad.make_line_redge((9.5, 1.0, plate_t), (8.0, -2.0, plate_t))
triangle_wire = scad.make_wire_from_edges_rwire([tri_a, tri_b, tri_c])
triangle_face = scad.make_face_from_wire_rface(triangle_wire)
triangle_boss = scad.extrude_rsolid(triangle_face, (0.0, 0.0, 1.0), rib_t)
# Convenience wire/face builders are included too; inside GraphSession they
# lower to the same canonical low-level edge/wire/face operations.
source_step("05 convenience wires/faces -> lowered canonical edge/wire/face nodes")
rectangle_wire = scad.make_rectangle_rwire(4.0, 2.0, center=(-13.0, 0.0, plate_t))
rectangle_face = scad.make_rectangle_rface(4.0, 2.0, center=(-13.0, 4.0, plate_t))
circle_wire = scad.make_circle_rwire((13.0, 4.0, plate_t), 1.0)
circle_face = scad.make_circle_rface((13.0, 0.0, plate_t), 1.0)
segment_wire = scad.make_segment_rwire((-13.0, -4.0, plate_t), (-9.0, -4.0, plate_t))
polyline_wire = scad.make_polyline_rwire(
[(-3.0, -5.0, plate_t), (-1.0, -4.0, plate_t), (1.0, -5.0, plate_t)]
)
arc_wire = scad.make_three_point_arc_rwire(
(3.0, -5.0, plate_t), (4.0, -4.0, plate_t), (5.0, -5.0, plate_t)
)
angle_arc_wire = scad.make_angle_arc_rwire((7.0, -5.0, plate_t), 1.0, 0.0, 1.57)
wire_spline_fit = scad.fit_cubic_bspline_control_points(
[(9.0, -5.0, plate_t), (10.0, -4.0, plate_t), (11.0, -5.0, plate_t)],
tolerance=0.01,
)
spline_wire = scad.make_spline_rwire(
control_points=wire_spline_fit.control_points,
knots=wire_spline_fit.unique_knots,
multiplicities=wire_spline_fit.multiplicities,
)
# Basic solid constructors that lower to replayable core operations.
source_step("06 make_sphere_rsolid, make_cone_rsolid")
sphere = scad.make_sphere_rsolid(1.0, center=(-7.0, 6.0, plate_t + 1.0))
cone = scad.make_cone_rsolid(
1.2,
2.0,
top_radius=0.4,
bottom_face_center=(-3.0, 6.0, plate_t),
)
# Feature operations.
source_step("07 revolve_rsolid, loft_rsolid, sweep_rsolid")
revolve_profile = scad.make_polyline_rwire(
[(0.5, 0.0, 0.0), (1.2, 0.0, 0.0), (1.0, 0.0, 1.6), (0.5, 0.0, 1.6)],
closed=True,
)
revolved_pin = scad.revolve_rsolid(
revolve_profile,
axis=(0.0, 0.0, 1.0),
angle=360.0,
origin=(0.0, 0.0, 0.0),
)
revolved_pin = scad.translate_shape(revolved_pin, (4.0, 6.0, plate_t))
loft_a = scad.make_rectangle_rwire(1.8, 1.2, center=(8.0, 6.0, plate_t))
loft_b = scad.make_rectangle_rwire(1.0, 0.8, center=(8.0, 6.0, plate_t + 2.0))
lofted_post = scad.loft_rsolid([loft_a, loft_b], ruled=True)
sweep_profile = scad.make_circle_rface((12.0, 6.0, plate_t), 0.35, normal=(1.0, 0.0, 0.0))
sweep_path = scad.make_polyline_rwire(
[(12.0, 6.0, plate_t), (14.0, 6.0, plate_t + 1.0), (15.5, 7.0, plate_t + 1.5)]
)
swept_pipe = scad.sweep_rsolid(sweep_profile, sweep_path, is_frenet=False)
# Composite operation: helical_sweep_rsolid is serialized as helix + face + sweep,
# not as a dedicated `helical_sweep` graph node.
source_step("08 helical_sweep_rsolid macro -> make_helix_redge + wire + face + sweep")
thread_profile = scad.make_rectangle_rwire(0.25, 0.18, center=(0.0, 0.0, 0.0))
helical_thread = scad.helical_sweep_rsolid(
thread_profile,
pitch=0.7,
height=2.2,
radius=0.9,
center=(13.0, -6.0, plate_t),
)
# Transforms and patterns. Pattern helpers serialize as explicit translate /
# rotate nodes instead of `linear_pattern` / `radial_pattern` macro nodes.
source_step("09 translate_shape, rotate_shape, mirror_shape")
rib = scad.make_box_rsolid(rib_t, plate_h * 0.55, plate_t * 1.4)
rib = scad.translate_shape(rib, (-plate_w / 4.0, 0.0, plate_t))
rib = scad.rotate_shape(rib, 0.0) # zero-angle shortcut, intentionally not recorded
rib_copy = scad.mirror_shape(rib, plane_origin=(0.0, 0.0, 0.0), plane_normal=(1.0, 0.0, 0.0))
source_step("10 linear_pattern_rsolidlist and radial_pattern_rsolidlist macro lowering")
lug_seed = scad.make_box_rsolid(1.2, 1.2, 1.0, bottom_face_center=(-12.0, -7.0, plate_t))
linear_lugs = scad.linear_pattern_rsolidlist(lug_seed, (1.0, 0.0, 0.0), count=3, spacing=3.0)
spoke_seed = scad.make_box_rsolid(0.8, 2.0, 0.8, bottom_face_center=(0.0, 5.2, plate_t))
radial_spokes = scad.radial_pattern_rsolidlist(
spoke_seed,
center=(0.0, 0.0, plate_t),
axis=(0.0, 0.0, 1.0),
count=4,
total_rotation_angle=360.0,
)
# Boolean operations. Boolean union must produce one connected solid, so
# this tiny demo uses overlapping boxes instead of trying to merge every
# separate showcase solid above.
source_step("11 union_rsolid, intersect_rsolid, cut_rsolid")
union_a = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-4.0, -7.0, 0.0))
union_b = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-2.5, -7.0, 0.0))
union_demo = scad.union_rsolid(union_a, union_b)
overlap_a = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(12.0, -2.0, plate_t))
overlap_b = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(13.0, -2.0, plate_t))
intersection_demo = scad.intersect_rsolid(overlap_a, overlap_b)
# Detail operations use QL selectors so the graph contains stable, serializable
# selection hints rather than Python object identity from source code.
source_step("12 fillet_rsolid, chamfer_rsolid, shell_rsolid with serializable selectors")
vertical_edges = Q.edges().where(Q.curve_type("line")).take(4)
final = scad.fillet_rsolid(union_demo, vertical_edges, fillet_r)
chamfer_box = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(2.0, -7.0, 0.0))
top_outer_edges = Q.edges().order_by(Q.center_axis("z"), desc=True).take(4)
chamfer_demo = scad.chamfer_rsolid(chamfer_box, top_outer_edges, 0.15)
# Keep shell separate so the demo includes shell without making the main part
# fragile. It remains a replayable leaf in model.json.
shell_box = scad.make_box_rsolid(4.0, 3.0, 2.0, bottom_face_center=(18.0, -7.0, 0.0))
top_face = Q.faces().order_by(Q.center_axis("z"), desc=True).take(1).exactly(1)
shell_demo = scad.shell_rsolid(shell_box, top_face, 0.25)
# Export the canonical model JSON and inspect how the graph maps back to source.
model_json = scad.export_model_json(session)
payload = json.loads(model_json)
MODEL_JSON_PATH.write_text(model_json, encoding="utf-8")
# Replay from model JSON to prove that the stored operation tree is sufficient.
rebuilt = scad.replay_model_json(model_json)
scad.export_step(rebuilt, str(STEP_PATH))
ops = [node["op"] for node in payload["graph"]["nodes"]]
op_counts = Counter(ops)
expr_nodes = payload["expression_graph"]["nodes"]
nodes_with_exprs = [
node for node in payload["graph"]["nodes"] if node.get("param_exprs")
]
# Build a compact source-to-graph explanation. This file is easier to read than
# the full JSON and is meant to be opened side by side with this Python source.
summary = dedent(
f"""
# Serialization Operation Tree Example
Source file: `examples/07_serialization_operation_tree.py`
Generated model JSON: `{MODEL_JSON_PATH}`
Generated STEP replay output: `{STEP_PATH}`
## What to compare
1. Read the `SOURCE STEP` comments / print output in the Python source.
2. Open the JSON and inspect `graph.nodes[*].op`, `params`, `param_exprs`, and `inputs`.
3. Notice that convenience API calls are lowered to canonical replayable operations.
## Basic counts
- graph nodes: `{len(payload['graph']['nodes'])}`
- graph edges: `{len(payload['graph']['edges'])}`
- leaf ids: `{len(payload['leaf_ids'])}` -> `{payload['leaf_ids']}`
- expression graph nodes: `{len(expr_nodes)}`
- operation nodes with `param_exprs`: `{len(nodes_with_exprs)}`
- replayed outputs: `{len(rebuilt)}`
## Canonical operation set observed
"""
).lstrip()
for op, count in sorted(op_counts.items()):
summary += f"- `{op}`: {count}\n"
summary += dedent(
"""
## Important source-code to graph mappings
- `make_box_rsolid(...)` does **not** appear as `make_box` in model JSON.
It lowers to `make_line_redge` + `make_wire_from_edges_rwire` +
`make_face_from_wire_rface` + `make_extrude_rsolid`.
- `make_cylinder_rsolid(...)` lowers to a circle face plus `make_extrude_rsolid`.
- `make_sphere_rsolid(...)` and `make_cone_rsolid(...)` lower to revolve chains.
- `make_rectangle_rwire`, `make_circle_rwire`, `make_polyline_rwire`, and
single-arc/spline/helix wire helpers lower to edge + wire operations.
- `linear_pattern_rsolidlist(...)` lowers to explicit `make_translate_rshape`
nodes.
- `radial_pattern_rsolidlist(...)` lowers to explicit `make_rotate_rshape`
nodes.
- `helical_sweep_rsolid(...)` lowers to helix + face + `make_sweep_rsolid`;
there is no `helical_sweep` node.
- Expression values are snapshotted into `params`; the symbolic links live in
`param_exprs` and the top-level `expression_graph`.
## Nodes that reference expressions
"""
)
for node in nodes_with_exprs[:40]:
summary += f"- `{node['node_id']}` `{node['op']}` param_exprs={json.dumps(node['param_exprs'], sort_keys=True)}\n"
if len(nodes_with_exprs) > 40:
summary += f"- ... {len(nodes_with_exprs) - 40} more expression-backed nodes\n"
SUMMARY_PATH.write_text(summary, encoding="utf-8")
print("wrote", MODEL_JSON_PATH)
print("wrote", SUMMARY_PATH)
print("wrote", STEP_PATH)
print("graph_nodes", len(payload["graph"]["nodes"]))
print("expression_nodes", len(expr_nodes))
print("leaf_ids", payload["leaf_ids"])
print("replayed_outputs", len(rebuilt))
print("observed_ops", ", ".join(sorted(op_counts)))
@@ -0,0 +1,397 @@
"""Constrained sketch-first modeling with isomorphic SimpleCADAPI calls.
Run from the repository root with:
uv run python examples/08_constrained_sketch.py
Generated files:
examples/out/constrained_sketch.model.json
examples/out/constrained_sketch.step
examples/out/constrained_sketch.fcstd
When the intent is a sketch/profile, use the sketch APIs. Concrete geometry
APIs remain for paths, pure geometry, and lowering targets.
"""
from __future__ import annotations
import json
from pathlib import Path
import simplecadapi as scad
OUT = Path("examples/out")
OUT.mkdir(parents=True, exist_ok=True)
MODEL_JSON_PATH = OUT / "constrained_sketch.model.json"
STEP_PATH = OUT / "constrained_sketch.step"
FCSTD_PATH = OUT / "constrained_sketch.fcstd"
FREECAD_CMD = Path("/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd")
def _solve_and_report(name: str, sketch: scad.Sketch) -> None:
result = scad.inspect_sketch_rsketchresult(
sketch,
require_fully_constrained=True,
)
points = sorted(
(point_id, round(point[0], 3), round(point[1], 3))
for point_id, point in result.solved_points.items()
)
scalars = sorted(
(key, round(value, 3)) for key, value in result.solved_scalars.items()
)
print(
f"{name}_sketch",
result.status,
"dof",
result.dof,
"residual",
f"{result.residual_norm:.2e}",
"points",
points[:4],
"scalars",
scalars[:2],
)
def _promote_face(name: str, sketch: scad.Sketch):
_solve_and_report(name, sketch)
return scad.make_face_from_sketch_rface(
sketch,
require_fully_constrained=True,
)
def make_rect_profile(name, x0, y0, width, height):
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.add_point_rsketch(sketch, "p0", x0, y0)
sketch = scad.add_point_rsketch(sketch, "p1", x0 + width, y0)
sketch = scad.add_point_rsketch(sketch, "p2", x0 + width, y0 + height)
sketch = scad.add_point_rsketch(sketch, "p3", x0, y0 + height)
sketch = scad.add_line_rsketch(sketch, "bottom", "p0", "p1")
sketch = scad.add_line_rsketch(sketch, "right", "p1", "p2")
sketch = scad.add_line_rsketch(sketch, "top", "p2", "p3")
sketch = scad.add_line_rsketch(sketch, "left", "p3", "p0")
sketch = scad.constrain_horizontal_rsketch(sketch, "bottom")
sketch = scad.constrain_vertical_rsketch(sketch, "right")
sketch = scad.constrain_parallel_rsketch(sketch, "bottom", "top")
sketch = scad.constrain_parallel_rsketch(sketch, "left", "right")
sketch = scad.constrain_perpendicular_rsketch(sketch, "bottom", "right")
sketch = scad.constrain_equal_length_rsketch(sketch, "bottom", "top")
sketch = scad.constrain_equal_length_rsketch(sketch, "left", "right")
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p1", width)
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p3", height)
sketch = scad.constrain_fix_rsketch(sketch, "p0")
return _promote_face(name, sketch)
def make_circle_profile(name, center_x, center_y, radius, circle_id):
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y)
sketch = scad.add_circle_rsketch(sketch, circle_id, "center", radius)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_radius_rsketch(sketch, circle_id, radius)
return _promote_face(name, sketch)
def make_guided_diamond_profile(name, center_x, center_y, width, height, guide_gap):
half_w = width / 2.0
half_h = height / 2.0
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y)
sketch = scad.add_point_rsketch(sketch, "left", center_x - half_w, center_y)
sketch = scad.add_point_rsketch(sketch, "top", center_x, center_y + half_h)
sketch = scad.add_point_rsketch(sketch, "right", center_x + half_w, center_y)
sketch = scad.add_point_rsketch(sketch, "bottom", center_x, center_y - half_h)
sketch = scad.add_point_rsketch(sketch, "guide_upper_start", center_x - half_w, center_y + guide_gap)
sketch = scad.add_point_rsketch(sketch, "guide_upper_end", center_x, center_y + half_h + guide_gap)
sketch = scad.add_point_rsketch(sketch, "guide_lower_start", center_x + half_w, center_y - guide_gap)
sketch = scad.add_point_rsketch(sketch, "guide_lower_end", center_x, center_y - half_h - guide_gap)
sketch = scad.add_line_rsketch(sketch, "bottom_left", "left", "bottom")
sketch = scad.add_line_rsketch(sketch, "right_bottom", "bottom", "right")
sketch = scad.add_line_rsketch(sketch, "top_right", "right", "top")
sketch = scad.add_line_rsketch(sketch, "left_top", "top", "left")
sketch = scad.add_line_rsketch(sketch, "guide_upper", "guide_upper_start", "guide_upper_end", construction=True)
sketch = scad.add_line_rsketch(sketch, "guide_lower", "guide_lower_start", "guide_lower_end", construction=True)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_distance_x_rsketch(sketch, "left", "center", half_w)
sketch = scad.constrain_distance_y_rsketch(sketch, "left", "center", 0.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "right", half_w)
sketch = scad.constrain_distance_y_rsketch(sketch, "center", "right", 0.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "top", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "center", "top", half_h)
sketch = scad.constrain_distance_x_rsketch(sketch, "bottom", "center", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "bottom", "center", half_h)
sketch = scad.constrain_parallel_rsketch(sketch, "left_top", "right_bottom")
sketch = scad.constrain_parallel_rsketch(sketch, "top_right", "bottom_left")
sketch = scad.constrain_equal_length_rsketch(sketch, "left_top", "top_right")
sketch = scad.constrain_equal_length_rsketch(sketch, "top_right", "right_bottom")
sketch = scad.constrain_equal_length_rsketch(sketch, "right_bottom", "bottom_left")
sketch = scad.constrain_distance_x_rsketch(sketch, "left", "guide_upper_start", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "left", "guide_upper_start", guide_gap)
sketch = scad.constrain_distance_x_rsketch(sketch, "top", "guide_upper_end", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "top", "guide_upper_end", guide_gap)
sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_start", "right", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_start", "right", guide_gap)
sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_end", "bottom", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_end", "bottom", guide_gap)
sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "guide_lower")
sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "right_bottom")
sketch = scad.constrain_parallel_rsketch(sketch, "guide_lower", "left_top")
sketch = scad.constrain_equal_length_rsketch(sketch, "guide_upper", "right_bottom")
sketch = scad.constrain_equal_length_rsketch(sketch, "guide_lower", "left_top")
return _promote_face(name, sketch)
def make_curve_guided_relief_profile(name, center_x, center_y, radius, guide_span):
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y)
sketch = scad.add_point_rsketch(sketch, "rim", center_x + radius, center_y)
sketch = scad.add_point_rsketch(sketch, "clearance_center", center_x, center_y)
sketch = scad.add_point_rsketch(sketch, "upper_left", center_x - guide_span, center_y + radius)
sketch = scad.add_point_rsketch(sketch, "upper_right", center_x + guide_span, center_y + radius)
sketch = scad.add_point_rsketch(sketch, "lower_left", center_x - guide_span, center_y - radius)
sketch = scad.add_point_rsketch(sketch, "lower_right", center_x + guide_span, center_y - radius)
sketch = scad.add_circle_rsketch(sketch, "relief", "center", radius)
sketch = scad.add_circle_rsketch(sketch, "clearance", "clearance_center", radius, construction=True)
sketch = scad.add_line_rsketch(sketch, "radius_probe", "center", "rim", construction=True)
sketch = scad.add_line_rsketch(sketch, "upper_rail", "upper_left", "upper_right", construction=True)
sketch = scad.add_line_rsketch(sketch, "lower_rail", "lower_left", "lower_right", construction=True)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_radius_rsketch(sketch, "relief", radius)
sketch = scad.constrain_point_on_rsketch(sketch, "rim", "relief")
sketch = scad.constrain_horizontal_rsketch(sketch, "radius_probe")
sketch = scad.constrain_length_rsketch(sketch, "radius_probe", radius)
sketch = scad.constrain_concentric_rsketch(sketch, "relief", "clearance")
sketch = scad.constrain_equal_radius_rsketch(sketch, "relief", "clearance")
sketch = scad.constrain_horizontal_rsketch(sketch, "upper_rail")
sketch = scad.constrain_horizontal_rsketch(sketch, "lower_rail")
sketch = scad.constrain_tangent_rsketch(sketch, "upper_rail", "relief")
sketch = scad.constrain_tangent_rsketch(sketch, "lower_rail", "relief")
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_left", -guide_span)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_right", guide_span)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_left", -guide_span)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_right", guide_span)
return _promote_face(name, sketch)
plate_w = scad.var("plate_w", 96.0, comment="plate width")
plate_h = scad.var("plate_h", 54.0, comment="plate height")
plate_t = scad.var("plate_t", 6.0, comment="plate thickness")
boss_r = scad.var("boss_r", 14.0, comment="raised center boss radius")
boss_h = scad.var("boss_h", 5.0, comment="raised center boss height")
bore_r = scad.var("bore_r", 5.0, comment="through bore radius")
mount_r = scad.var("mount_r", 3.0, comment="mounting hole radius")
margin_x = scad.var("mount_margin_x", 12.0, comment="mounting hole x margin")
margin_y = scad.var("mount_margin_y", 9.0, comment="mounting hole y margin")
slot_w = scad.var("slot_w", 34.0, comment="service slot width")
slot_h = scad.var("slot_h", 8.0, comment="service slot height")
slot_y = scad.var("slot_center_y", 16.0, comment="service slot center y")
diamond_w = scad.var("guided_diamond_w", 14.0, comment="guided diamond pocket width")
diamond_h = scad.var("guided_diamond_h", 8.0, comment="guided diamond pocket height")
diamond_guide_gap = scad.var("guided_diamond_guide_gap", 5.0, comment="parallel guide rail offset")
relief_r = scad.var("curve_relief_r", 4.0, comment="curve-guided relief radius")
relief_guide_span = scad.var("curve_relief_guide_span", 9.0, comment="curve relief construction rail half span")
center_x = plate_w / 2.0
center_y = plate_h / 2.0
with scad.GraphSession() as session:
plate_profile = make_rect_profile("plate_outline", 0.0, 0.0, plate_w, plate_h)
plate_profile = scad.apply_tag(plate_profile, "demo.profile.plate")
plate = scad.extrude_rsolid(plate_profile, (0.0, 0.0, 1.0), plate_t)
plate = scad.apply_tag(plate, "demo.body.base_plate")
boss_profile = make_circle_profile(
"center_boss",
center_x,
center_y,
boss_r,
"boss_outer",
)
boss_overlap = 1.0
boss = scad.extrude_rsolid(boss_profile, (0.0, 0.0, 1.0), boss_h + boss_overlap)
boss = scad.translate_shape(boss, (0.0, 0.0, plate_t - boss_overlap))
boss = scad.apply_tag(boss, "demo.body.raised_boss")
body = scad.union_rsolid(plate, boss, glue=False)
bore_profile = make_circle_profile(
"center_bore",
center_x,
center_y,
bore_r,
"bore",
)
bore_cutter = scad.extrude_rsolid(
bore_profile,
(0.0, 0.0, 1.0),
plate_t + boss_h + 2.0,
)
bore_cutter = scad.translate_shape(bore_cutter, (0.0, 0.0, -1.0))
slot_profile = make_rect_profile(
"service_slot",
center_x - slot_w / 2.0,
slot_y - slot_h / 2.0,
slot_w,
slot_h,
)
slot_cutter = scad.extrude_rsolid(slot_profile, (0.0, 0.0, 1.0), plate_t + 2.0)
slot_cutter = scad.translate_shape(slot_cutter, (0.0, 0.0, -1.0))
diamond_profile = make_guided_diamond_profile(
"guided_diamond_pocket",
plate_w - 24.0,
plate_h - 18.0,
diamond_w,
diamond_h,
diamond_guide_gap,
)
diamond_cutter = scad.extrude_rsolid(diamond_profile, (0.0, 0.0, 1.0), plate_t + 2.0)
diamond_cutter = scad.translate_shape(diamond_cutter, (0.0, 0.0, -1.0))
curve_relief_profile = make_curve_guided_relief_profile(
"curve_guided_relief",
plate_w / 3.0,
plate_h - 12.0,
relief_r,
relief_guide_span,
)
curve_relief_cutter = scad.extrude_rsolid(curve_relief_profile, (0.0, 0.0, 1.0), plate_t + 2.0)
curve_relief_cutter = scad.translate_shape(curve_relief_cutter, (0.0, 0.0, -1.0))
mount_centers = [
("mount_sw", margin_x, margin_y),
("mount_se", plate_w - margin_x, margin_y),
("mount_ne", plate_w - margin_x, plate_h - margin_y),
("mount_nw", margin_x, plate_h - margin_y),
]
mount_cutters = []
for name, x_pos, y_pos in mount_centers:
mount_profile = make_circle_profile(name, x_pos, y_pos, mount_r, "mount_hole")
mount_cutter = scad.extrude_rsolid(
mount_profile,
(0.0, 0.0, 1.0),
plate_t + 2.0,
)
mount_cutters.append(scad.translate_shape(mount_cutter, (0.0, 0.0, -1.0)))
part = scad.cut_rsolid(
body,
bore_cutter,
slot_cutter,
diamond_cutter,
curve_relief_cutter,
mount_cutters,
skip_non_intersecting=False,
)
part = scad.apply_tag(part, "demo.constrained_sketch_bracket")
model_json = scad.export_model_json(session)
MODEL_JSON_PATH.write_text(model_json, encoding="utf-8")
rebuilt = scad.replay_model_json(model_json)
scad.export_step(rebuilt, str(STEP_PATH))
freecad_cmd = str(FREECAD_CMD) if FREECAD_CMD.exists() else None
scad.translator.freecad_translator.translate_model_json_to_fcstd(
model_json,
str(FCSTD_PATH),
document_name="SimpleCADConstrainedSketchDemo",
freecad_cmd=freecad_cmd,
)
payload = json.loads(model_json)
ops = [node["op"] for node in payload["graph"]["nodes"]]
promotion_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] in {"make_face_from_sketch_rface", "make_wire_from_sketch_rwire"}
]
diamond_promotion = next(
node
for node in promotion_nodes
if node["params"]["sketch"].get("name") == "guided_diamond_pocket"
)
diamond_constraints = diamond_promotion["params"]["sketch"].get("constraints", [])
curve_promotion = next(
node
for node in promotion_nodes
if node["params"]["sketch"].get("name") == "curve_guided_relief"
)
curve_constraints = curve_promotion["params"]["sketch"].get("constraints", [])
sketch_entity_tags = sorted(
tag
for edge in scad.ql.select(plate_profile.get_edges()).where(
scad.ql.tag("sketch_entity.*")
).all()
for tag in scad.list_tags(edge)
if tag.startswith("sketch_entity.")
)
diamond_entity_tags = sorted(
tag
for edge in scad.ql.select(diamond_profile.get_edges()).where(
scad.ql.tag("sketch_entity.*")
).all()
for tag in scad.list_tags(edge)
if tag.startswith("sketch_entity.")
)
curve_entity_tags = sorted(
tag
for edge in scad.ql.select(curve_relief_profile.get_edges()).where(
scad.ql.tag("sketch_entity.*")
).all()
for tag in scad.list_tags(edge)
if tag.startswith("sketch_entity.")
)
print("graph_nodes", len(ops))
print("sketch_ops", sum(1 for op in ops if "sketch" in op))
print("promotion_nodes", len(promotion_nodes))
print(
"promotion_solve_snapshots",
sum(1 for node in promotion_nodes if "solve_snapshot" in node.get("params", {})),
)
print("contains_public_solve_node", "make_solve_sketch_rsketchresult" in ops)
print("plate_sketch_entity_tags", sketch_entity_tags)
print("diamond_sketch_entity_tags", diamond_entity_tags)
print("diamond_constraint_count", len(diamond_constraints))
print(
"diamond_parallel_equal_constraints",
sum(
1
for constraint in diamond_constraints
if constraint.get("kind") in {"parallel", "equal_length"}
),
)
print("curve_sketch_entity_tags", curve_entity_tags)
print("curve_constraint_count", len(curve_constraints))
print(
"curve_tangent_equal_radius_constraints",
sum(
1
for constraint in curve_constraints
if constraint.get("kind") in {"tangent", "equal_radius", "concentric", "point_on"}
),
)
print("volume", round(part.get_volume(), 3))
print("wrote", MODEL_JSON_PATH)
print("wrote", STEP_PATH)
print("wrote", FCSTD_PATH)
@@ -0,0 +1,96 @@
"""NACA 0016 propeller blade with exact BSpline FreeCAD translation.
Run from the repository root with:
uv run python examples/09_naca0016_blade_freecad.py
Generated files:
examples/out/naca0016_blade/naca0016_blade.model.json
examples/out/naca0016_blade/naca0016_blade.step
examples/out/naca0016_blade/naca0016_blade.fcstd
The NACA section generator starts from sampled airfoil points. The evolve helper
fits those samples into exact cubic B-spline control data before calling
`make_spline_rwire(...)`, so the exported model JSON and FreeCAD document contain
exact B-spline payloads rather than sampled-point spline approximations.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import simplecadapi as scad
DEFAULT_OUTPUT_DIR = Path("examples/out/naca0016_blade")
DEFAULT_FREECAD_CMD = Path("/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd")
def build_blade(output_dir: Path, *, freecad_cmd: Path | None = DEFAULT_FREECAD_CMD) -> dict:
output_dir.mkdir(parents=True, exist_ok=True)
model_json_path = output_dir / "naca0016_blade.model.json"
step_path = output_dir / "naca0016_blade.step"
fcstd_path = output_dir / "naca0016_blade.fcstd"
with scad.GraphSession() as session:
blade = scad.make_naca_propeller_blade_rsolid(
blade_length=4.0,
root_chord=1.25,
tip_chord=0.35,
total_twist_angle=36.0,
num_sections=6,
)
blade = scad.apply_tag(blade, "role.naca0016.blade")
model_json = scad.export_model_json(session)
payload = json.loads(model_json)
model_json_path.write_text(model_json, encoding="utf-8")
scad.export_step(blade, str(step_path))
bspline_nodes = [
node for node in payload["graph"]["nodes"] if node.get("op") == "make_spline_redge"
]
loft_nodes = [node for node in payload["graph"]["nodes"] if node.get("op") == "make_loft_rsolid"]
control_counts = [len(node["params"].get("control_points", [])) for node in bspline_nodes]
knot_counts = [len(node["params"].get("knots", [])) for node in bspline_nodes]
print("graph_nodes", len(payload["graph"]["nodes"]))
print("leaf_ids", payload["leaf_ids"])
print("bspline_section_nodes", len(bspline_nodes))
print("loft_nodes", len(loft_nodes))
print("bspline_control_counts", control_counts[:6])
print("bspline_knot_counts", knot_counts[:6])
print("volume", round(blade.get_volume(), 6))
print("wrote", model_json_path)
print("wrote", step_path)
if freecad_cmd is not None and freecad_cmd.exists():
scad.translator.freecad_translator.translate_model_json_to_fcstd(
model_json,
str(fcstd_path),
document_name="NACA0016Blade",
freecad_cmd=str(freecad_cmd),
)
print("wrote", fcstd_path)
else:
print("skipped_fcstd", "FreeCADCmd not found")
return payload
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
parser.add_argument(
"--freecad-cmd",
type=Path,
default=DEFAULT_FREECAD_CMD,
help="FreeCADCmd path used to write .fcstd; skipped if the path does not exist.",
)
args = parser.parse_args()
build_blade(args.output_dir, freecad_cmd=args.freecad_cmd)
if __name__ == "__main__":
main()
@@ -0,0 +1,263 @@
"""Build a hydraulic rod assembly with separate sleeve and piston-rod parts."""
from __future__ import annotations
import json
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql
OUT_DIR = Path("examples/out/hydraulic_rod_assembly")
def build_hydraulic_rod_assembly():
flange_holes = [
(0.0, 16.0),
(16.0, 0.0),
(0.0, -16.0),
(-16.0, 0.0),
]
with scad.GraphSession() as session:
barrel = scad.make_cylinder_rsolid(
radius=16.0,
height=120.0,
bottom_face_center=(-60.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
rod_gland_flange = scad.make_cylinder_rsolid(
radius=22.0,
height=12.0,
bottom_face_center=(50.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
rod_gland_nose = scad.make_cylinder_rsolid(
radius=13.0,
height=10.0,
bottom_face_center=(58.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
base_cap = scad.make_cylinder_rsolid(
radius=18.0,
height=12.0,
bottom_face_center=(-66.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
rear_eye = scad.make_cylinder_rsolid(
radius=14.0,
height=12.0,
bottom_face_center=(-80.0, -6.0, 0.0),
axis=(0.0, 1.0, 0.0),
)
rear_eye_neck = scad.make_box_rsolid(
18.0,
14.0,
16.0,
bottom_face_center=(-68.0, 0.0, -8.0),
)
sleeve_raw = scad.union_rsolid(
barrel,
rod_gland_flange,
rod_gland_nose,
base_cap,
rear_eye,
rear_eye_neck,
glue=False,
)
sleeve_solid = scad.cut_rsolid(
sleeve_raw,
scad.make_cylinder_rsolid(
radius=10.5,
height=136.0,
bottom_face_center=(-68.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
),
)
sleeve_solid = scad.cut_rsolid(
sleeve_solid,
scad.make_cylinder_rsolid(
radius=4.6,
height=26.0,
bottom_face_center=(-80.0, -13.0, 0.0),
axis=(0.0, 1.0, 0.0),
),
)
for y, z in flange_holes:
sleeve_solid = scad.cut_rsolid(
sleeve_solid,
scad.make_cylinder_rsolid(
radius=1.8,
height=16.0,
bottom_face_center=(48.0, y, z),
axis=(1.0, 0.0, 0.0),
),
)
piston_land_left = scad.make_cylinder_rsolid(
radius=10.0,
height=3.2,
bottom_face_center=(-6.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
piston_seal_groove = scad.make_cylinder_rsolid(
radius=9.0,
height=6.0,
bottom_face_center=(-3.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
piston_land_right = scad.make_cylinder_rsolid(
radius=10.0,
height=3.2,
bottom_face_center=(2.6, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
chrome_rod = scad.make_cylinder_rsolid(
radius=6.5,
height=132.0,
bottom_face_center=(3.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
rod_eye = scad.make_cylinder_rsolid(
radius=13.0,
height=9.0,
bottom_face_center=(143.0, -4.5, 0.0),
axis=(0.0, 1.0, 0.0),
)
rod_eye_neck = scad.make_box_rsolid(
20.0,
8.0,
13.0,
bottom_face_center=(130.0, 0.0, -6.5),
)
piston_rod_raw = scad.union_rsolid(
piston_land_left,
piston_seal_groove,
piston_land_right,
chrome_rod,
rod_eye,
rod_eye_neck,
glue=False,
)
rod_eye_pin_hole = scad.make_cylinder_rsolid(
radius=5.5,
height=13.0,
bottom_face_center=(143.0, -6.5, 0.0),
axis=(0.0, 1.0, 0.0),
)
piston_rod_solid = scad.cut_rsolid(piston_rod_raw, rod_eye_pin_hole)
black_oxide_steel = scad.make_material_rmaterial(
"black_oxide_steel",
name="Black oxide steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.10, 0.11, 0.12),
)
chrome_steel = scad.make_material_rmaterial(
"chrome_plated_steel",
name="Chrome plated steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.78, 0.80, 0.82),
)
sleeve_part = scad.make_part_rpart(
"outer_sleeve", sleeve_solid, name="Outer sleeve with clevis and gland"
)
sleeve_part = scad.assign_material_rpart(sleeve_part, black_oxide_steel)
sleeve_faces = ql.faces().resolve(sleeve_solid)
sleeve_end_face = None
for f in sleeve_faces:
n = f.get_normal_at()
if abs(abs(n.x) - 1.0) < 0.01 and f.get_area() < 1000.0:
sleeve_end_face = f
break
sleeve_connector = scad.make_face_connector_rconnector("slide_axis", sleeve_end_face)
sleeve_part = scad.add_connector_rpart(sleeve_part, sleeve_connector)
piston_rod_part = scad.make_part_rpart(
"piston_rod", piston_rod_solid, name="Inner piston rod with eye end"
)
piston_rod_part = scad.assign_material_rpart(piston_rod_part, chrome_steel)
rod_faces = ql.faces().resolve(piston_rod_solid)
rod_end_face = None
for f in rod_faces:
n = f.get_normal_at()
if abs(abs(n.x) - 1.0) < 0.01 and f.get_area() < 1000.0:
rod_end_face = f
break
sleeve_normal = sleeve_end_face.get_normal_at()
rod_normal = rod_end_face.get_normal_at()
rod_flip = (sleeve_normal.x * rod_normal.x) < 0
rod_connector = scad.make_face_connector_rconnector("slide_axis", rod_end_face, flip=rod_flip)
piston_rod_part = scad.add_connector_rpart(piston_rod_part, rod_connector)
hydraulic_assembly = scad.make_assembly_rassembly(
"hydraulic_rod_assembly", name="Hydraulic rod assembly"
)
hydraulic_assembly = scad.add_component_rassembly(
hydraulic_assembly,
sleeve_part,
component_id="outer_sleeve",
placement=scad.identity_placement_rplacement(),
)
hydraulic_assembly = scad.add_component_rassembly(
hydraulic_assembly,
piston_rod_part,
component_id="inner_piston_rod",
placement=scad.identity_placement_rplacement(),
)
hydraulic_assembly = scad.ground_component_rassembly(
hydraulic_assembly, "outer_sleeve"
)
hydraulic_assembly = scad.add_prismatic_constraint_rassembly(
hydraulic_assembly,
"rod_slide",
scad.make_connector_ref_rconnectorref("outer_sleeve", "slide_axis"),
scad.make_connector_ref_rconnectorref("inner_piston_rod", "slide_axis"),
drive_distance=0.0,
distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 100.0),
)
hydraulic_assembly = scad.solve_assembly_constraints_rassembly(
hydraulic_assembly
)
preview = scad.make_compound_from_assembly_rcompound(hydraulic_assembly)
model_json = scad.export_model_json(session)
return hydraulic_assembly, preview, model_json
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
assembly, preview, model_json = build_hydraulic_rod_assembly()
model_path = OUT_DIR / "hydraulic_rod_assembly.model.json"
step_path = OUT_DIR / "hydraulic_rod_assembly.step"
fcstd_path = OUT_DIR / "hydraulic_rod_assembly.FCStd"
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(preview, str(step_path))
fcstd_status = "skipped"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
fcstd_status = str(fcstd_path)
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__})"
payload = json.loads(model_json)
face_count = len(ql.faces().resolve(preview))
print("assembly", assembly.assembly_id)
print("components", assembly.component_ids())
print("preview_solids", len(preview.get_solids()))
print("preview_faces", face_count)
print("preview_volume", round(preview.get_volume(), 3))
print("graph_nodes", len(payload["graph"]["nodes"]))
print("wrote", model_path)
print("wrote", step_path)
print("fcstd", fcstd_status)
if __name__ == "__main__":
main()
@@ -0,0 +1,119 @@
"""Example 11: standalone std.gear internal ring gears.
This example intentionally avoids planetary assemblies. It exports three
separate ring-gear models so the internal tooth profile can be inspected
without overlapped sun/planet gears or assembly placement noise:
- spur internal ring gear
- helical internal ring gear
- herringbone internal ring gear
Each model is exported as model JSON, STEP, and FCStd.
"""
import json
import sys
from pathlib import Path
import simplecadapi as scad
# Ring gear sketches contain many profile entities and produce deep graphs.
sys.setrecursionlimit(10000)
MODULE = 1.5
RING_TEETH = 66
GEAR_HEIGHT = 8.0
HELIX_ANGLE = 25.0
RIM_THICKNESS = 4.0
BACKLASH = 0.08 * MODULE
OUTPUT_DIR = Path("examples/out/ring_gears")
def _export_ring(name, description, build_ring):
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUTPUT_DIR / f"{name}.model.json"
step_path = OUTPUT_DIR / f"{name}.step"
fcstd_path = OUTPUT_DIR / f"{name}.FCStd"
with scad.GraphSession() as session:
ring = build_ring()
model_json = scad.export_model_json(session)
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(ring, str(step_path))
fcstd_status = str(fcstd_path)
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
except Exception as exc:
fcstd_status = f"skipped ({exc.__class__.__name__})"
payload = json.loads(model_json)
print(f"=== {description} ===")
print(f" volume: {ring.get_volume():.1f}")
print(f" graph nodes: {len(payload['graph']['nodes'])}")
print(f" model: {model_path}")
print(f" step: {step_path}")
print(f" fcstd: {fcstd_status}")
print()
def main():
print(
"ring_z={ring_z} module={module} height={height} "
"rim={rim} helix={helix} backlash={backlash}".format(
ring_z=RING_TEETH,
module=MODULE,
height=GEAR_HEIGHT,
rim=RIM_THICKNESS,
helix=HELIX_ANGLE,
backlash=BACKLASH,
)
)
print(f"output_dir={OUTPUT_DIR}")
print()
_export_ring(
"spur_ring_gear",
"Spur internal ring gear",
lambda: scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=RING_TEETH,
module=MODULE,
gear_height=GEAR_HEIGHT,
rim_thickness=RIM_THICKNESS,
backlash=BACKLASH,
),
)
_export_ring(
"helical_ring_gear",
"Helical internal ring gear",
lambda: scad.std.gear.make_helical_ring_gear_rsolid(
n_teeth=RING_TEETH,
module=MODULE,
helix_angle=HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
rim_thickness=RIM_THICKNESS,
backlash=BACKLASH,
),
)
_export_ring(
"herringbone_ring_gear",
"Herringbone internal ring gear",
lambda: scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=RING_TEETH,
module=MODULE,
helix_angle=HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
rim_thickness=RIM_THICKNESS,
backlash=BACKLASH,
),
)
if __name__ == "__main__":
main()
@@ -0,0 +1,636 @@
"""Example 12: herringbone planetary reducer carrier assembly.
This example builds a planetary reducer layout with a fixed internal ring gear:
- one sun-drive carrier plate with a central shaft
- one herringbone sun gear fixed to that sun-drive plate
- one fixed herringbone internal ring gear
- one upper Y-shaped planet-carrier output plate with three pins
- one reusable herringbone planet gear Part instanced three times
The ring gear is the grounded reference in this static CAD assembly. The sun
gear is fixed to the input shaft, while the planet carrier and each planet gear
use revolute joints so the product structure reflects the intended power path:
sun input -> planet gears against fixed ring -> slower planet-carrier output.
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql
# Gear sketches contain many profile entities and produce deep graphs.
sys.setrecursionlimit(20000)
MODULE = 1.5
SUN_TEETH = 18
PLANET_TEETH = 24
RING_TEETH = SUN_TEETH + 2 * PLANET_TEETH
PLANET_COUNT = 3
GEAR_HEIGHT = 8.0
SUN_HELIX_ANGLE = 25.0
PLANET_HELIX_ANGLE = -SUN_HELIX_ANGLE
RING_HELIX_ANGLE = PLANET_HELIX_ANGLE
RING_RIM_THICKNESS = 5.0
RING_BACKLASH = 0.08 * MODULE
SUN_PITCH_RADIUS = MODULE * SUN_TEETH / 2.0
PLANET_PITCH_RADIUS = MODULE * PLANET_TEETH / 2.0
RING_PITCH_RADIUS = MODULE * RING_TEETH / 2.0
SUN_BORE_RADIUS = 4.2
PLANET_BORE_RADIUS = 3.5
SUN_SHAFT_RADIUS = SUN_BORE_RADIUS - 0.2
SUN_AXIS_SHOULDER_RADIUS = SUN_BORE_RADIUS - 0.05
PLANET_PIN_RADIUS = PLANET_BORE_RADIUS - 0.7
PLANET_PIN_BEARING_RADIUS = PLANET_BORE_RADIUS - 0.3
SUN_DRIVE_PLATE_RADIUS = 14.0
SUN_DRIVE_PLATE_THICKNESS = 4.0
SUN_DRIVE_PLATE_BOTTOM_Z = -8.0
PLANET_CARRIER_THICKNESS = 3.0
PLANET_CARRIER_BOTTOM_Z = GEAR_HEIGHT + 1.0
CARRIER_AXIS_CONNECTOR_Z = PLANET_CARRIER_BOTTOM_Z + PLANET_CARRIER_THICKNESS
SUN_AXIS_CONNECTOR_Z = GEAR_HEIGHT
PLANET_AXIS_CONNECTOR_Z = GEAR_HEIGHT
PLANET_PIN_BOTTOM_Z = -0.25
PLANET_PIN_TOP_CLEARANCE = 0.5
CARRIER_CENTER_CLEARANCE_RADIUS = SUN_BORE_RADIUS + 1.0
CARRIER_HUB_RADIUS = CARRIER_CENTER_CLEARANCE_RADIUS + 4.5
CARRIER_ARM_WIDTH = 8.0
CARRIER_ARM_INNER_CLEARANCE = 0.6
CARRIER_ARM_END_OVERHANG = 1.0
PLANET_PAD_RADIUS = PLANET_BORE_RADIUS + 4.5
SUN_SHAFT_TOP_Z = CARRIER_AXIS_CONNECTOR_Z
OUTPUT_DIR = Path("examples/out/herringbone_planetary_gears")
def _planet_spin_angle(carrier_angle_deg: float) -> float:
"""Phase each planet so a tooth gap faces the sun contact line."""
planet_half_pitch_deg = 180.0 / PLANET_TEETH
return carrier_angle_deg + 180.0 - planet_half_pitch_deg
def _z_rotation_placement(origin: tuple[float, float, float], angle_degrees: float):
angle_rad = math.radians(angle_degrees)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(cos_a, sin_a, 0.0),
y_axis=(-sin_a, cos_a, 0.0),
)
def _ground_solid(label: str, solid: scad.Solid) -> None:
faces = ql.select(solid.get_faces()).all()
tagged_role_faces = ql.select(faces).where(ql.tag("role.*")).all()
print(
f"{label}: faces={len(faces)} role_faces={len(tagged_role_faces)} "
f"volume={solid.get_volume():.1f} tags={','.join(scad.list_tags(solid))}"
)
def _ground_compound(label: str, compound: scad.Compound) -> None:
solids = ql.select(compound.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volume = sum(solid.get_volume() for solid in solids)
print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.1f}")
def _axis_face(
label: str,
solid: scad.Solid,
center_xy: tuple[float, float],
target_z: float,
normal_z: float,
) -> scad.Face:
candidates = []
for face in ql.select(solid.get_faces()).all():
normal = face.get_normal_at()
if normal_z > 0.0 and normal.z < 0.7:
continue
if normal_z < 0.0 and normal.z > -0.7:
continue
center = face.get_center()
xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1])
z_error = abs(center.z - target_z)
candidates.append((z_error * 100.0 + xy_error, face, center, normal))
if not candidates:
raise ValueError(f"no connector face found for {label}")
_score, face, center, normal = min(candidates, key=lambda item: item[0])
print(
f"{label}_connector_face: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) "
f"normal=({normal.x:.3f},{normal.y:.3f},{normal.z:.3f}) area={face.get_area():.3f}"
)
return face
def _cut_axial_bore(label: str, solid: scad.Solid, radius: float) -> scad.Solid:
cutter = scad.make_cylinder_rsolid(
radius=radius,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
bored = scad.cut_rsolid(solid, cutter, skip_non_intersecting=False)
bored = scad.apply_tag(bored, f"solid.cut.{label}")
faces = ql.select(bored.get_faces()).all()
print(
f"{label}: bore_radius={radius:.2f} faces={len(faces)} "
f"volume={bored.get_volume():.1f} tags={','.join(scad.list_tags(bored))}"
)
return bored
def _build_sun_drive_plate() -> scad.Solid:
plate = scad.make_cylinder_rsolid(
radius=SUN_DRIVE_PLATE_RADIUS,
height=SUN_DRIVE_PLATE_THICKNESS,
bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
shaft = scad.make_cylinder_rsolid(
radius=SUN_SHAFT_RADIUS,
height=SUN_SHAFT_TOP_Z - SUN_DRIVE_PLATE_BOTTOM_Z,
bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
sun_axis_shoulder = scad.make_cylinder_rsolid(
radius=SUN_AXIS_SHOULDER_RADIUS,
height=SUN_AXIS_CONNECTOR_Z - SUN_DRIVE_PLATE_BOTTOM_Z,
bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
drive_plate = scad.union_rsolid(plate, sun_axis_shoulder, shaft, glue=False)
drive_plate = scad.apply_tag(drive_plate, "role.sun_drive_plate")
drive_plate = scad.apply_tag(drive_plate, "group.herringbone_planetary")
_ground_solid("sun_drive_plate", drive_plate)
return drive_plate
def _build_planet_carrier(planet_center_radius: float) -> scad.Solid:
arm_inner_x = CARRIER_CENTER_CLEARANCE_RADIUS + CARRIER_ARM_INNER_CLEARANCE
arm_outer_x = planet_center_radius + PLANET_PAD_RADIUS + CARRIER_ARM_END_OVERHANG
arm_length = arm_outer_x - arm_inner_x
arm_center_x = (arm_inner_x + arm_outer_x) / 2.0
pin_height = (
PLANET_CARRIER_BOTTOM_Z
+ PLANET_CARRIER_THICKNESS
+ PLANET_PIN_TOP_CLEARANCE
- PLANET_PIN_BOTTOM_Z
)
hub = scad.make_cylinder_rsolid(
radius=CARRIER_HUB_RADIUS,
height=PLANET_CARRIER_THICKNESS,
bottom_face_center=(0.0, 0.0, PLANET_CARRIER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
solids = [hub]
for index in range(PLANET_COUNT):
carrier_angle_deg = 360.0 * index / PLANET_COUNT
carrier_angle_rad = math.radians(carrier_angle_deg)
arm = scad.make_box_rsolid(
CARRIER_ARM_WIDTH,
arm_length,
PLANET_CARRIER_THICKNESS,
bottom_face_center=(arm_center_x, 0.0, PLANET_CARRIER_BOTTOM_Z),
)
if carrier_angle_deg != 0.0:
arm = scad.rotate_shape(
arm,
carrier_angle_deg,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
solids.append(arm)
center = (
planet_center_radius * math.cos(carrier_angle_rad),
planet_center_radius * math.sin(carrier_angle_rad),
)
solids.append(
scad.make_cylinder_rsolid(
radius=PLANET_PAD_RADIUS,
height=PLANET_CARRIER_THICKNESS,
bottom_face_center=(center[0], center[1], PLANET_CARRIER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=PLANET_PIN_RADIUS,
height=pin_height,
bottom_face_center=(center[0], center[1], PLANET_PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=PLANET_PIN_BEARING_RADIUS,
height=PLANET_AXIS_CONNECTOR_Z - PLANET_PIN_BOTTOM_Z,
bottom_face_center=(center[0], center[1], PLANET_PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
carrier = scad.union_rsolid(solids, glue=False)
carrier = scad.cut_rsolid(
carrier,
scad.make_cylinder_rsolid(
radius=CARRIER_CENTER_CLEARANCE_RADIUS,
height=PLANET_CARRIER_THICKNESS + 2.0,
bottom_face_center=(0.0, 0.0, PLANET_CARRIER_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
),
skip_non_intersecting=False,
)
carrier = scad.apply_tag(carrier, "role.planet_carrier")
carrier = scad.apply_tag(carrier, "group.herringbone_planetary")
print(
f"planet_carrier_y_top: arms={PLANET_COUNT} arm_width={CARRIER_ARM_WIDTH:.2f} "
f"arm_length={arm_length:.2f} top_z={PLANET_CARRIER_BOTTOM_Z + PLANET_CARRIER_THICKNESS:.2f} "
f"hub_radius={CARRIER_HUB_RADIUS:.2f} pad_radius={PLANET_PAD_RADIUS:.2f}"
)
_ground_solid("planet_carrier", carrier)
return carrier
def build_herringbone_planetary_gearset():
"""Build the open planetary carrier assembly and return preview/model JSON."""
planet_center_radius = MODULE * (SUN_TEETH + PLANET_TEETH) / 2.0
with scad.GraphSession() as session:
sun_drive_plate = _build_sun_drive_plate()
planet_carrier = _build_planet_carrier(planet_center_radius)
ring = scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=RING_TEETH,
module=MODULE,
helix_angle=RING_HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
rim_thickness=RING_RIM_THICKNESS,
backlash=RING_BACKLASH,
)
ring = scad.apply_tag(ring, "role.fixed_ring_gear")
ring = scad.apply_tag(ring, "group.herringbone_planetary")
_ground_solid("fixed_ring", ring)
sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=SUN_TEETH,
module=MODULE,
helix_angle=SUN_HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
)
sun = _cut_axial_bore("sun_bore", sun, SUN_BORE_RADIUS)
sun = scad.apply_tag(sun, "role.sun_gear")
sun = scad.apply_tag(sun, "group.herringbone_planetary")
_ground_solid("sun", sun)
planet_base = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=PLANET_TEETH,
module=MODULE,
helix_angle=PLANET_HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
)
planet_base = _cut_axial_bore("planet_bore", planet_base, PLANET_BORE_RADIUS)
planet_base = scad.apply_tag(planet_base, "role.planet_gear")
planet_base = scad.apply_tag(planet_base, "group.herringbone_planetary")
_ground_solid("planet_part", planet_base)
carrier_material = scad.make_material_rmaterial(
"matte_anodized_aluminum",
name="Matte anodized aluminum",
density=2.7e-6,
density_unit="kg/mm^3",
color=(0.28, 0.30, 0.32),
)
gear_material = scad.make_material_rmaterial(
"case_hardened_gear_steel",
name="Case hardened gear steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.68, 0.70, 0.72),
)
print(f"materials: {carrier_material.material_id},{gear_material.material_id}")
ring_part = scad.make_part_rpart(
"fixed_herringbone_ring",
ring,
name="Fixed herringbone internal ring gear",
)
ring_part = scad.assign_material_rpart(ring_part, gear_material)
ring_part = scad.add_connector_rpart(
ring_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face("ring_axis", ring, (0.0, 0.0), GEAR_HEIGHT, 1.0),
),
)
sun_drive_part = scad.make_part_rpart(
"sun_drive_plate",
sun_drive_plate,
name="Grounded sun-drive plate with central shaft",
)
sun_drive_part = scad.assign_material_rpart(sun_drive_part, carrier_material)
sun_drive_part = scad.add_connector_rpart(
sun_drive_part,
scad.make_face_connector_rconnector(
"carrier_axis",
_axis_face(
"sun_drive_carrier_axis",
sun_drive_plate,
(0.0, 0.0),
CARRIER_AXIS_CONNECTOR_Z,
1.0,
),
),
)
sun_drive_part = scad.add_connector_rpart(
sun_drive_part,
scad.make_face_connector_rconnector(
"sun_axis",
_axis_face(
"sun_drive_sun_axis",
sun_drive_plate,
(0.0, 0.0),
SUN_AXIS_CONNECTOR_Z,
1.0,
),
),
)
carrier_part = scad.make_part_rpart(
"planet_carrier",
planet_carrier,
name="Planet carrier output plate with three pins",
)
carrier_part = scad.assign_material_rpart(carrier_part, carrier_material)
carrier_part = scad.add_connector_rpart(
carrier_part,
scad.make_face_connector_rconnector(
"carrier_axis",
_axis_face(
"planet_carrier_axis",
planet_carrier,
(0.0, 0.0),
CARRIER_AXIS_CONNECTOR_Z,
1.0,
),
),
)
for index in range(PLANET_COUNT):
carrier_angle_deg = 360.0 * index / PLANET_COUNT
carrier_angle_rad = math.radians(carrier_angle_deg)
center_xy = (
planet_center_radius * math.cos(carrier_angle_rad),
planet_center_radius * math.sin(carrier_angle_rad),
)
carrier_part = scad.add_connector_rpart(
carrier_part,
scad.make_face_connector_rconnector(
f"planet_{index + 1}_axis",
_axis_face(
f"carrier_planet_{index + 1}_axis",
planet_carrier,
center_xy,
PLANET_AXIS_CONNECTOR_Z,
1.0,
),
),
)
sun_part = scad.make_part_rpart(
"herringbone_sun", sun, name="Herringbone sun gear"
)
sun_part = scad.assign_material_rpart(sun_part, gear_material)
sun_part = scad.add_connector_rpart(
sun_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face("sun_axis", sun, (0.0, 0.0), GEAR_HEIGHT, 1.0),
),
)
planet_part = scad.make_part_rpart(
"herringbone_planet", planet_base, name="Reusable herringbone planet gear"
)
planet_part = scad.assign_material_rpart(planet_part, gear_material)
planet_part = scad.add_connector_rpart(
planet_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face("planet_axis", planet_base, (0.0, 0.0), GEAR_HEIGHT, 1.0),
),
)
print(
"parts: "
f"{ring_part.part_id},{sun_drive_part.part_id},{carrier_part.part_id},"
f"{sun_part.part_id},{planet_part.part_id}"
)
gearset = scad.make_assembly_rassembly(
"herringbone_planetary_gearset",
name="Fixed-ring herringbone planetary reducer assembly",
)
gearset = scad.add_component_rassembly(
gearset,
ring_part,
component_id="fixed_ring",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Grounded fixed internal ring gear",
)
gearset = scad.add_component_rassembly(
gearset,
sun_drive_part,
component_id="sun_drive_plate",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Grounded sun-drive input plate",
)
gearset = scad.add_component_rassembly(
gearset,
carrier_part,
component_id="planet_carrier",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Planet-carrier output plate",
)
gearset = scad.add_component_rassembly(
gearset,
sun_part,
component_id="sun",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Sun gear fixed to input plate",
)
for index in range(PLANET_COUNT):
carrier_angle_deg = 360.0 * index / PLANET_COUNT
carrier_angle_rad = math.radians(carrier_angle_deg)
center = (
planet_center_radius * math.cos(carrier_angle_rad),
planet_center_radius * math.sin(carrier_angle_rad),
0.0,
)
spin_angle = _planet_spin_angle(carrier_angle_deg)
gearset = scad.add_component_rassembly(
gearset,
planet_part,
component_id=f"planet_{index + 1}",
placement=_z_rotation_placement(center, spin_angle),
name=f"Planet gear {index + 1}",
)
print(
f"planet_{index + 1}: carrier={carrier_angle_deg:.1f}deg "
f"center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f}) "
f"spin={spin_angle:.1f}deg"
)
gearset = scad.ground_component_rassembly(gearset, "fixed_ring")
gearset = scad.add_revolute_constraint_rassembly(
gearset,
"sun_input_revolute",
scad.make_connector_ref_rconnectorref("fixed_ring", "axis"),
scad.make_connector_ref_rconnectorref("sun_drive_plate", "sun_axis"),
name="Sun input shaft rotates inside the fixed ring gear",
)
gearset = scad.add_revolute_constraint_rassembly(
gearset,
"carrier_output_revolute",
scad.make_connector_ref_rconnectorref("sun_drive_plate", "carrier_axis"),
scad.make_connector_ref_rconnectorref("planet_carrier", "carrier_axis"),
name="Planet carrier rotates around the sun-drive plate axis",
)
gearset = scad.add_fixed_constraint_rassembly(
gearset,
"sun_fixed_to_drive_plate",
scad.make_connector_ref_rconnectorref("sun_drive_plate", "sun_axis"),
scad.make_connector_ref_rconnectorref("sun", "axis"),
name="Sun gear fixed to the input shaft",
)
for index in range(PLANET_COUNT):
gearset = scad.add_revolute_constraint_rassembly(
gearset,
f"planet_{index + 1}_revolute",
scad.make_connector_ref_rconnectorref("planet_carrier", f"planet_{index + 1}_axis"),
scad.make_connector_ref_rconnectorref(f"planet_{index + 1}", "axis"),
name=f"Planet gear {index + 1} rotates on its carrier pin",
)
for index in range(PLANET_COUNT):
planet_ref = scad.make_connector_ref_rconnectorref(
component_id=f"planet_{index + 1}",
connector_id="axis",
)
gearset = scad.add_gear_constraint_rassembly(
assembly=gearset,
constraint_id=f"sun_planet_{index + 1}_external_mesh",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="sun_drive_plate",
connector_id="sun_axis",
),
connector_b=planet_ref,
pitch_radius_a=SUN_PITCH_RADIUS,
pitch_radius_b=PLANET_PITCH_RADIUS,
name=f"External sun to planet {index + 1} gear mesh",
)
gearset = scad.add_belt_constraint_rassembly(
assembly=gearset,
constraint_id=f"ring_planet_{index + 1}_internal_mesh",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_ring",
connector_id="axis",
),
connector_b=planet_ref,
pulley_radius_a=RING_PITCH_RADIUS,
pulley_radius_b=PLANET_PITCH_RADIUS,
name=f"Internal fixed-ring to planet {index + 1} gear mesh",
)
print(
"gear_constraints: "
f"sun_planet_external={PLANET_COUNT} ring_planet_internal={PLANET_COUNT} "
f"radii=({SUN_PITCH_RADIUS:.3f},{PLANET_PITCH_RADIUS:.3f},{RING_PITCH_RADIUS:.3f})"
)
gearset = scad.solve_assembly_constraints_rassembly(gearset)
report = scad.inspect_assembly_constraints_rconstraintreport(gearset)
print(
"assembly: "
f"components={','.join(gearset.component_ids())} "
f"grounded={','.join(gearset.grounded_component_ids)} "
f"solved={report.solved} constraints={len(gearset.constraints)}"
)
for residual in report.residuals:
print(
f"constraint_{residual.constraint_id}: "
f"translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} "
f"ok={residual.within_tolerance}"
)
preview = scad.make_compound_from_assembly_rcompound(gearset)
_ground_compound("assembly_preview", preview)
model_json = scad.export_model_json(session)
return gearset, preview, model_json
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUTPUT_DIR / "herringbone_planetary_gearset.model.json"
step_path = OUTPUT_DIR / "herringbone_planetary_gearset.step"
fcstd_path = OUTPUT_DIR / "herringbone_planetary_gearset.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json = build_herringbone_planetary_gearset()
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(preview, str(step_path))
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"failed ({exc.__class__.__name__}: {exc})"
payload = json.loads(model_json)
replayed = scad.replay_model_json(model_json)
solids = ql.select(preview.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volumes = [solid.get_volume() for solid in solids]
print(
"sun_z={sun} planet_z={planet} ring_z={ring} planets={count} "
"module={module} height={height} sun_helix={sun_helix} planet_helix={planet_helix}".format(
sun=SUN_TEETH,
planet=PLANET_TEETH,
ring=RING_TEETH,
count=PLANET_COUNT,
module=MODULE,
height=GEAR_HEIGHT,
sun_helix=SUN_HELIX_ANGLE,
planet_helix=PLANET_HELIX_ANGLE,
)
)
print(f"planet_center_radius={MODULE * (SUN_TEETH + PLANET_TEETH) / 2.0:.3f}")
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print(f"preview_solids={len(solids)}")
print(f"preview_faces={face_count}")
print("volumes=" + ",".join(f"{volume:.1f}" for volume in volumes))
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,847 @@
"""Example 13: compact 50 mm x 10 mm cycloidal reducer assembly.
Design plan
===========
Package envelope:
- Maximum outside diameter: 50 mm.
- Maximum stack height: 10 mm.
Reduction stage:
- Fixed pin ring: 11 pins.
- Twin cycloidal discs: 10 lobes each, stacked with 180 degree eccentric
carrier separation and a half-lobe tooth-index phase for load balance.
- Single-stage reduction: 11 - 1 = 10:1.
- The fixed pin ring contact is represented as a gear-like coupling between the
input eccentric carrier and each cycloidal disc's relative spin: each disc
spins -11/10 turn relative to its eccentric carrier for each input turn,
giving a global cycloidal/output phase of -1/10 input turn.
Structure:
- fixed_housing: outer sleeve, top/bottom retainers, and 11 fixed ring pins.
- input_disk: bottom three-hole threaded mounting disk plus a double eccentric
cam shaft. The lower cam is at 0 degrees; the upper cam is at 180 degrees.
- lower_cycloidal_disc and upper_cycloidal_disc: 10-lobed discs with eccentric
bearing bores and three oversize output-pin relief holes. The upper disc is
tooth-indexed by half a lobe, i.e. 180 degrees divided by 10 lobes = 18
geometric degrees. A full 180 degree rotation would be symmetry-equivalent
to the lower disc because the profile has 10 lobes.
- output_disk: top three-hole threaded mounting disk plus three output pins.
Assembly relationships:
- fixed_housing is grounded.
- input_disk is revolute about the housing axis.
- output_disk is revolute about the housing axis.
- lower_cycloidal_disc is revolute on the lower input eccentric cam axis.
- upper_cycloidal_disc is revolute on the upper input eccentric cam axis.
- input_disk to each cycloidal disc has a gear-like pin-ring rolling coupling.
The output pins are fixed to the output disk and pass through oversize circular
holes in both cycloidal discs. The two discs load those pins from opposite
eccentric directions, so the real mechanism keeps the output-pin side load more
balanced through a full rotation. This SDK does not yet have a native
pin-slot/contact primitive, so the example models that relation as clearance
geometry rather than a false coaxial ratio shortcut.
Each cycloidal outline is fit as ten cubic B-spline segments, one segment per
lobe. This keeps the exported topology small and stable while preserving the
analytic pin-wheel profile within a controlled fit tolerance.
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql
sys.setrecursionlimit(20000)
PACKAGE_DIAMETER = 50.0
PACKAGE_RADIUS = PACKAGE_DIAMETER / 2.0
PACKAGE_HEIGHT = 10.0
OUTPUT_DIR = Path("examples/out/cycloidal_reducer_50mm_10x")
PIN_COUNT = 11
CYCLOID_LOBES = PIN_COUNT - 1
REDUCTION_RATIO = CYCLOID_LOBES
ECCENTRICITY = 0.8
LOWER_ECCENTRIC_CENTER = (ECCENTRICITY, 0.0)
UPPER_ECCENTRIC_CENTER = (-ECCENTRICITY, 0.0)
UPPER_CYCLOID_BODY_PHASE_DEGREES = 180.0 / CYCLOID_LOBES
RING_PIN_PITCH_RADIUS = 18.0
RING_PIN_RADIUS = 0.65
PROFILE_ROLLER_RADIUS = 1.6
HOUSING_INNER_RADIUS = 22.3
RETAINER_INNER_RADIUS = 13.5
BOTTOM_RETAINER_BOTTOM_Z = 1.25
RETAINER_THICKNESS = 0.75
TOP_RETAINER_BOTTOM_Z = 8.0
PIN_BOTTOM_Z = BOTTOM_RETAINER_BOTTOM_Z
PIN_TOP_Z = TOP_RETAINER_BOTTOM_Z + RETAINER_THICKNESS
INPUT_FLANGE_RADIUS = 11.8
OUTPUT_FLANGE_RADIUS = 11.8
FLANGE_THICKNESS = 1.1
INPUT_FLANGE_BOTTOM_Z = 0.0
OUTPUT_FLANGE_BOTTOM_Z = PACKAGE_HEIGHT - FLANGE_THICKNESS
MOUNT_HOLE_COUNT = 3
MOUNT_HOLE_RADIUS = 1.03
MOUNT_HOLE_ENTRY_RADIUS = 1.35
MOUNT_HOLE_ENTRY_DEPTH = 0.28
MOUNT_HOLE_PITCH_RADIUS = 8.8
ECCENTRIC_BOSS_RADIUS = 3.1
INPUT_SHAFT_RADIUS = 0.55
INPUT_CAM_DATUM_PAD_RADIUS = 0.22
INPUT_CAM_DATUM_PAD_HEIGHT = 0.06
INPUT_CAM_DATUM_PAD_OVERLAP = 0.02
CYCLOID_BORE_RADIUS = 3.45
LOWER_CYCLOID_BOTTOM_Z = 2.15
CYCLOID_DISC_HEIGHT = 2.65
CYCLOID_DISC_GAP = 0.20
CYCLOID_BEARING_RACE_HEIGHT = 0.10
LOWER_CYCLOID_TOP_Z = LOWER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT
LOWER_CYCLOID_CONNECTOR_Z = LOWER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT
UPPER_CYCLOID_BOTTOM_Z = LOWER_CYCLOID_CONNECTOR_Z + CYCLOID_DISC_GAP
UPPER_CYCLOID_TOP_Z = UPPER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT
UPPER_CYCLOID_CONNECTOR_Z = UPPER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT
CYCLOID_STACK_HEIGHT = UPPER_CYCLOID_CONNECTOR_Z - LOWER_CYCLOID_BOTTOM_Z
CYCLOID_LOBE_SAMPLE_COUNT = 33
CYCLOID_SPLINE_TOLERANCE = 0.005
CYCLOID_SPLINE_MAX_CONTROL_POINTS = 20
OUTPUT_PIN_COUNT = 3
OUTPUT_PIN_RADIUS = 1.0
OUTPUT_PIN_CLEARANCE_RADIUS = OUTPUT_PIN_RADIUS + ECCENTRICITY + 0.25
OUTPUT_PIN_PITCH_RADIUS = 6.4
OUTPUT_PIN_PHASE = 60.0
OUTPUT_PIN_BOTTOM_Z = LOWER_CYCLOID_BOTTOM_Z
OUTPUT_PIN_TOP_Z = OUTPUT_FLANGE_BOTTOM_Z + 0.20
def _polar(radius: float, angle_degrees: float) -> tuple[float, float]:
angle = math.radians(angle_degrees)
return radius * math.cos(angle), radius * math.sin(angle)
def _z_rotation_placement(origin: tuple[float, float, float], angle_degrees: float):
angle = math.radians(angle_degrees)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
)
def _ground_solid(label: str, solid: scad.Solid) -> None:
faces = ql.select(solid.get_faces()).all()
role_faces = ql.select(faces).where(ql.tag("role.*")).all()
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"volume={solid.get_volume():.1f} tags={','.join(scad.list_tags(solid))}"
)
def _ground_compound(label: str, compound: scad.Compound) -> None:
solids = ql.select(compound.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volume = sum(solid.get_volume() for solid in solids)
print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.1f}")
def _axis_face(
label: str,
solid: scad.Solid,
center_xy: tuple[float, float],
target_z: float,
normal_z: float,
) -> scad.Face:
candidates = []
for face in ql.select(solid.get_faces()).all():
normal = face.get_normal_at()
if normal_z > 0.0 and normal.z < 0.7:
continue
if normal_z < 0.0 and normal.z > -0.7:
continue
center = face.get_center()
xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1])
z_error = abs(center.z - target_z)
candidates.append((z_error * 100.0 + xy_error, face, center, normal))
if not candidates:
raise ValueError(f"no connector face found for {label}")
_score, face, center, normal = min(candidates, key=lambda item: item[0])
print(
f"{label}_connector_face: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) "
f"normal=({normal.x:.3f},{normal.y:.3f},{normal.z:.3f}) area={face.get_area():.3f}"
)
return face
def _make_annular_cylinder(
*,
outer_radius: float,
inner_radius: float,
bottom_z: float,
height: float,
) -> scad.Solid:
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
inner = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
return scad.cut_rsolid(outer, inner, skip_non_intersecting=False)
def _cut_three_threaded_hole_envelopes(
solid: scad.Solid,
*,
bottom_z: float,
thickness: float,
entry_face: str,
phase_degrees: float = 0.0,
) -> scad.Solid:
cutters: list[scad.Solid] = []
for index in range(MOUNT_HOLE_COUNT):
angle = phase_degrees + 360.0 * index / MOUNT_HOLE_COUNT
x, y = _polar(MOUNT_HOLE_PITCH_RADIUS, angle)
cutters.append(
scad.make_cylinder_rsolid(
radius=MOUNT_HOLE_RADIUS,
height=thickness + 0.4,
bottom_face_center=(x, y, bottom_z - 0.2),
axis=(0.0, 0.0, 1.0),
)
)
if entry_face == "bottom":
entry_bottom_z = bottom_z - 0.04
elif entry_face == "top":
entry_bottom_z = bottom_z + thickness - MOUNT_HOLE_ENTRY_DEPTH
else:
raise ValueError("entry_face must be 'bottom' or 'top'")
cutters.append(
scad.make_cylinder_rsolid(
radius=MOUNT_HOLE_ENTRY_RADIUS,
height=MOUNT_HOLE_ENTRY_DEPTH + 0.08,
bottom_face_center=(x, y, entry_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
return scad.cut_rsolid(solid, cutters, skip_non_intersecting=False)
def _build_fixed_housing() -> scad.Solid:
sleeve = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=HOUSING_INNER_RADIUS,
bottom_z=0.0,
height=PACKAGE_HEIGHT,
)
bottom_retainer = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=RETAINER_INNER_RADIUS,
bottom_z=BOTTOM_RETAINER_BOTTOM_Z,
height=RETAINER_THICKNESS,
)
top_retainer = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=RETAINER_INNER_RADIUS,
bottom_z=TOP_RETAINER_BOTTOM_Z,
height=RETAINER_THICKNESS,
)
pins: list[scad.Solid] = []
for index in range(PIN_COUNT):
angle = 360.0 * index / PIN_COUNT
x, y = _polar(RING_PIN_PITCH_RADIUS, angle)
pins.append(
scad.make_cylinder_rsolid(
radius=RING_PIN_RADIUS,
height=PIN_TOP_Z - PIN_BOTTOM_Z,
bottom_face_center=(x, y, PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
housing = scad.union_rsolid(
sleeve,
bottom_retainer,
top_retainer,
pins,
glue=False,
)
housing = scad.apply_tag(housing, "role.fixed_pin_housing")
housing = scad.apply_tag(housing, "group.cycloidal_reducer")
_ground_solid("fixed_housing", housing)
return housing
def _build_input_disk() -> scad.Solid:
flange = scad.make_cylinder_rsolid(
radius=INPUT_FLANGE_RADIUS,
height=FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, INPUT_FLANGE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
flange = _cut_three_threaded_hole_envelopes(
flange,
bottom_z=INPUT_FLANGE_BOTTOM_Z,
thickness=FLANGE_THICKNESS,
entry_face="bottom",
phase_degrees=0.0,
)
cam_bottom_z = FLANGE_THICKNESS - 0.20
input_shaft = scad.make_cylinder_rsolid(
radius=INPUT_SHAFT_RADIUS,
height=UPPER_CYCLOID_CONNECTOR_Z - cam_bottom_z,
bottom_face_center=(0.0, 0.0, cam_bottom_z),
axis=(0.0, 0.0, 1.0),
)
lower_pad_bottom_z = LOWER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT
lower_eccentric_boss = scad.make_cylinder_rsolid(
radius=ECCENTRIC_BOSS_RADIUS,
height=lower_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - cam_bottom_z,
bottom_face_center=(*LOWER_ECCENTRIC_CENTER, cam_bottom_z),
axis=(0.0, 0.0, 1.0),
)
lower_datum_pad = scad.make_cylinder_rsolid(
radius=INPUT_CAM_DATUM_PAD_RADIUS,
height=INPUT_CAM_DATUM_PAD_HEIGHT,
bottom_face_center=(*LOWER_ECCENTRIC_CENTER, lower_pad_bottom_z),
axis=(0.0, 0.0, 1.0),
)
upper_boss_bottom_z = UPPER_CYCLOID_BOTTOM_Z - 0.10
upper_pad_bottom_z = UPPER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT
upper_eccentric_boss = scad.make_cylinder_rsolid(
radius=ECCENTRIC_BOSS_RADIUS,
height=upper_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - upper_boss_bottom_z,
bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_boss_bottom_z),
axis=(0.0, 0.0, 1.0),
)
upper_datum_pad = scad.make_cylinder_rsolid(
radius=INPUT_CAM_DATUM_PAD_RADIUS,
height=INPUT_CAM_DATUM_PAD_HEIGHT,
bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_pad_bottom_z),
axis=(0.0, 0.0, 1.0),
)
input_disk = scad.union_rsolid(
flange,
input_shaft,
lower_eccentric_boss,
lower_datum_pad,
upper_eccentric_boss,
upper_datum_pad,
glue=False,
)
input_disk = scad.apply_tag(input_disk, "role.input_three_thread_disk")
input_disk = scad.apply_tag(input_disk, "role.double_eccentric_camshaft")
input_disk = scad.apply_tag(input_disk, "group.cycloidal_reducer")
_ground_solid("input_disk", input_disk)
return input_disk
def _build_cycloidal_disc(
*,
label: str,
bottom_z: float,
output_pin_phase: float,
body_phase_degrees: float,
role_tag: str,
) -> scad.Solid:
disc = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=CYCLOID_LOBES,
ring_pin_pitch_radius=RING_PIN_PITCH_RADIUS,
roller_radius=PROFILE_ROLLER_RADIUS,
eccentricity=ECCENTRICITY,
gear_height=CYCLOID_DISC_HEIGHT,
bore_radius=CYCLOID_BORE_RADIUS,
output_pin_count=OUTPUT_PIN_COUNT,
output_pin_pitch_radius=OUTPUT_PIN_PITCH_RADIUS,
output_pin_clearance_radius=OUTPUT_PIN_CLEARANCE_RADIUS,
output_pin_phase=output_pin_phase,
sample_count_per_lobe=CYCLOID_LOBE_SAMPLE_COUNT,
spline_tolerance=CYCLOID_SPLINE_TOLERANCE,
max_control_points=CYCLOID_SPLINE_MAX_CONTROL_POINTS,
)
cycloid_meta = disc.get_metadata("std.gear.cycloidal_disc", {})
top_z = bottom_z + CYCLOID_DISC_HEIGHT
connector_z = top_z + CYCLOID_BEARING_RACE_HEIGHT
disc = scad.translate_shape(disc, (0.0, 0.0, bottom_z))
bearing_race = _make_annular_cylinder(
outer_radius=CYCLOID_BORE_RADIUS + 0.75,
inner_radius=CYCLOID_BORE_RADIUS,
bottom_z=top_z - 0.02,
height=CYCLOID_BEARING_RACE_HEIGHT + 0.02,
)
disc = scad.union_rsolid(disc, bearing_race, glue=False)
if body_phase_degrees:
disc = scad.rotate_shape(
disc,
body_phase_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
disc = scad.apply_tag(disc, role_tag)
disc = scad.apply_tag(disc, "role.ten_lobe_cycloidal_disc")
disc = scad.apply_tag(disc, "group.cycloidal_reducer")
print(
f"{label}_profile: "
f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} "
f"bottom_z={bottom_z:.2f} connector_z={connector_z:.2f} "
f"body_phase={body_phase_degrees:.1f} "
f"raw_output_pin_phase={output_pin_phase:.1f} "
f"segments={cycloid_meta.get('segment_count', CYCLOID_LOBES)} "
f"samples_per_lobe={CYCLOID_LOBE_SAMPLE_COUNT} "
f"control_points={min(cycloid_meta.get('control_point_counts', [0]))}.."
f"{max(cycloid_meta.get('control_point_counts', [0]))} "
f"fit_error_max={max(cycloid_meta.get('max_errors', [0.0])):.5f} "
f"radius_min={cycloid_meta.get('radius_min', 0.0):.3f} "
f"radius_max={cycloid_meta.get('radius_max', 0.0):.3f}"
)
_ground_solid(label, disc)
return disc
def _build_output_disk() -> scad.Solid:
flange = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_RADIUS,
height=FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
flange = _cut_three_threaded_hole_envelopes(
flange,
bottom_z=OUTPUT_FLANGE_BOTTOM_Z,
thickness=FLANGE_THICKNESS,
entry_face="top",
phase_degrees=0.0,
)
pins: list[scad.Solid] = []
for index in range(OUTPUT_PIN_COUNT):
angle = OUTPUT_PIN_PHASE + 360.0 * index / OUTPUT_PIN_COUNT
x, y = _polar(OUTPUT_PIN_PITCH_RADIUS, angle)
pins.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_PIN_RADIUS,
height=OUTPUT_PIN_TOP_Z - OUTPUT_PIN_BOTTOM_Z,
bottom_face_center=(x, y, OUTPUT_PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
output_disk = scad.union_rsolid(flange, pins, glue=False)
output_disk = scad.apply_tag(output_disk, "role.output_three_thread_disk")
output_disk = scad.apply_tag(output_disk, "group.cycloidal_reducer")
_ground_solid("output_disk", output_disk)
return output_disk
def build_cycloidal_reducer():
with scad.GraphSession() as session:
housing = _build_fixed_housing()
input_disk = _build_input_disk()
lower_cycloidal_disc = _build_cycloidal_disc(
label="lower_cycloidal_disc",
bottom_z=LOWER_CYCLOID_BOTTOM_Z,
output_pin_phase=OUTPUT_PIN_PHASE,
body_phase_degrees=0.0,
role_tag="role.lower_cycloidal_disc",
)
upper_cycloidal_disc = _build_cycloidal_disc(
label="upper_cycloidal_disc",
bottom_z=UPPER_CYCLOID_BOTTOM_Z,
output_pin_phase=OUTPUT_PIN_PHASE - UPPER_CYCLOID_BODY_PHASE_DEGREES,
body_phase_degrees=UPPER_CYCLOID_BODY_PHASE_DEGREES,
role_tag="role.upper_cycloidal_disc",
)
output_disk = _build_output_disk()
housing_material = scad.make_material_rmaterial(
material_id="black_anodized_aluminum",
name="Black anodized aluminum",
density=2.7e-6,
density_unit="kg/mm^3",
color=(0.08, 0.08, 0.09),
)
steel_material = scad.make_material_rmaterial(
material_id="bearing_steel",
name="Bearing steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.62, 0.64, 0.66),
)
bronze_material = scad.make_material_rmaterial(
material_id="phosphor_bronze",
name="Phosphor bronze",
density=8.8e-6,
density_unit="kg/mm^3",
color=(0.72, 0.48, 0.20),
)
print(
"materials: "
f"{housing_material.material_id},{steel_material.material_id},{bronze_material.material_id}"
)
housing_part = scad.make_part_rpart(
part_id="fixed_pin_housing",
body=housing,
name="Fixed housing with eleven pin ring",
)
housing_part = scad.assign_material_rpart(housing_part, housing_material)
housing_part = scad.add_connector_rpart(
housing_part,
scad.make_face_connector_rconnector(
"input_axis",
_axis_face(
"housing_input_axis",
housing,
(0.0, 0.0),
INPUT_FLANGE_BOTTOM_Z,
-1.0,
),
flip=True,
),
)
housing_part = scad.add_connector_rpart(
housing_part,
scad.make_face_connector_rconnector(
"output_axis",
_axis_face(
"housing_output_axis",
housing,
(0.0, 0.0),
PACKAGE_HEIGHT,
1.0,
),
),
)
input_part = scad.make_part_rpart(
part_id="input_three_thread_disk",
body=input_disk,
name="Input three threaded-hole disk with double eccentric camshaft",
)
input_part = scad.assign_material_rpart(input_part, steel_material)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face(
"input_axis",
input_disk,
(0.0, 0.0),
INPUT_FLANGE_BOTTOM_Z,
-1.0,
),
flip=True,
),
)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"lower_eccentric_axis",
_axis_face(
"input_lower_eccentric_axis",
input_disk,
LOWER_ECCENTRIC_CENTER,
LOWER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"upper_eccentric_axis",
_axis_face(
"input_upper_eccentric_axis",
input_disk,
UPPER_ECCENTRIC_CENTER,
UPPER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
lower_cycloid_part = scad.make_part_rpart(
part_id="lower_ten_lobe_cycloidal_disc",
body=lower_cycloidal_disc,
name="Lower ten-lobe cycloidal disc",
)
lower_cycloid_part = scad.assign_material_rpart(
lower_cycloid_part, bronze_material
)
lower_cycloid_part = scad.add_connector_rpart(
lower_cycloid_part,
scad.make_face_connector_rconnector(
"eccentric_axis",
_axis_face(
"lower_cycloid_eccentric_axis",
lower_cycloidal_disc,
(0.0, 0.0),
LOWER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
upper_cycloid_part = scad.make_part_rpart(
part_id="upper_ten_lobe_cycloidal_disc",
body=upper_cycloidal_disc,
name="Upper ten-lobe cycloidal disc, 180 degree phased",
)
upper_cycloid_part = scad.assign_material_rpart(
upper_cycloid_part, bronze_material
)
upper_cycloid_part = scad.add_connector_rpart(
upper_cycloid_part,
scad.make_face_connector_rconnector(
"eccentric_axis",
_axis_face(
"upper_cycloid_eccentric_axis",
upper_cycloidal_disc,
(0.0, 0.0),
UPPER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
output_part = scad.make_part_rpart(
part_id="output_three_thread_disk",
body=output_disk,
name="Output three threaded-hole disk with drive pins",
)
output_part = scad.assign_material_rpart(output_part, steel_material)
output_part = scad.add_connector_rpart(
output_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face(
"output_axis",
output_disk,
(0.0, 0.0),
PACKAGE_HEIGHT,
1.0,
),
),
)
reducer = scad.make_assembly_rassembly(
assembly_id="cycloidal_reducer_50mm_10x",
name="50 mm diameter 10:1 cycloidal reducer",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=housing_part,
component_id="fixed_housing",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Grounded fixed pin-ring housing",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=input_part,
component_id="input_disk",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Input three-thread-hole disk",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=lower_cycloid_part,
component_id="lower_cycloidal_disc",
placement=_z_rotation_placement((ECCENTRICITY, 0.0, 0.0), 0.0),
name="Lower cycloidal disc riding on 0 degree eccentric cam",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=upper_cycloid_part,
component_id="upper_cycloidal_disc",
placement=_z_rotation_placement((-ECCENTRICITY, 0.0, 0.0), 0.0),
name="Upper cycloidal disc riding on 180 degree eccentric cam",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=output_part,
component_id="output_disk",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Output three-thread-hole disk",
)
reducer = scad.ground_component_rassembly(reducer, "fixed_housing")
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="input_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_housing", connector_id="input_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
name="Input disk rotates in the fixed housing",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="output_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_housing", connector_id="output_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="output_disk", connector_id="axis"
),
name="Output disk rotates coaxially in the fixed housing",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="lower_cycloid_on_eccentric_cam",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="lower_eccentric_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="lower_cycloidal_disc", connector_id="eccentric_axis"
),
name="Lower cycloidal disc rotates on the 0 degree input eccentric cam",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="upper_cycloid_on_eccentric_cam",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="upper_eccentric_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="upper_cycloidal_disc", connector_id="eccentric_axis"
),
name="Upper cycloidal disc rotates on the 180 degree input eccentric cam",
)
reducer = scad.add_gear_constraint_rassembly(
assembly=reducer,
constraint_id="fixed_pin_ring_to_lower_cycloid_spin",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="lower_cycloidal_disc", connector_id="eccentric_axis"
),
pitch_radius_a=float(PIN_COUNT),
pitch_radius_b=float(CYCLOID_LOBES),
name="Fixed pin ring drives the lower cycloidal disc relative spin",
)
reducer = scad.add_gear_constraint_rassembly(
assembly=reducer,
constraint_id="fixed_pin_ring_to_upper_cycloid_spin",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="upper_cycloidal_disc", connector_id="eccentric_axis"
),
pitch_radius_a=float(PIN_COUNT),
pitch_radius_b=float(CYCLOID_LOBES),
name="Fixed pin ring drives the upper cycloidal disc relative spin",
)
print(
"assembly_plan: "
f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} "
f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} reduction={REDUCTION_RATIO}:1 "
f"eccentricity={ECCENTRICITY:.2f} cycloid_discs=2 "
f"eccentric_phase_degrees=0,180 "
f"tooth_index_phase_degrees=0,{UPPER_CYCLOID_BODY_PHASE_DEGREES:.1f} "
f"stack_height={CYCLOID_STACK_HEIGHT:.2f}"
)
print(
"load_balance: "
"lower_eccentric=(+e,0) upper_eccentric=(-e,0) "
"output_pins_pass_through_both_discs contact_not_solved"
)
print(
"kinematic_relation: "
f"each_cycloid_relative=-{PIN_COUNT}/{CYCLOID_LOBES}*input "
f"each_cycloid_global=output=-1/{REDUCTION_RATIO}*input via output pin holes"
)
reducer = scad.solve_assembly_constraints_rassembly(reducer)
report = scad.inspect_assembly_constraints_rconstraintreport(reducer)
print(
"assembly: "
f"components={','.join(reducer.component_ids())} "
f"grounded={','.join(reducer.grounded_component_ids)} "
f"solved={report.solved} constraints={len(reducer.constraints)}"
)
for residual in report.residuals:
print(
f"constraint_{residual.constraint_id}: "
f"translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} "
f"ok={residual.within_tolerance}"
)
preview = scad.make_compound_from_assembly_rcompound(reducer)
_ground_compound("assembly_preview", preview)
model_json = scad.export_model_json(session)
return reducer, preview, model_json
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.model.json"
step_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.step"
fcstd_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json = build_cycloidal_reducer()
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(preview, str(step_path))
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"failed ({exc.__class__.__name__}: {exc})"
payload = json.loads(model_json)
replayed = scad.replay_model_json(model_json)
solids = ql.select(preview.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volumes = [solid.get_volume() for solid in solids]
print(
"package: "
f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} "
f"outer_radius={PACKAGE_RADIUS:.1f}"
)
print(
"mounting: "
f"input=3xM2.5_envelope output=3xM2.5_envelope "
f"hole_pcd={2.0 * MOUNT_HOLE_PITCH_RADIUS:.1f}"
)
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print(f"preview_solids={len(solids)}")
print(f"preview_faces={face_count}")
print("volumes=" + ",".join(f"{volume:.1f}" for volume in volumes))
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,168 @@
"""Example 14: parameterized ball bearing standard assembly.
This example builds a small radial ball bearing through
``scad.std.bearing.make_ball_bearing_rassembly`` and then keeps working inside
that assembly by binding a demo shaft to the inner ring and a demo housing to
the outer ring. The important bearing semantics are product-level, not just
geometry: stable component ids expose the rings and balls, and the inner and
outer rings are connected by a revolute constraint.
"""
from __future__ import annotations
import json
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql
OUT_DIR = Path("examples/out/ball_bearing_608_demo")
BORE_DIAMETER = 8.0
OUTER_DIAMETER = 22.0
BEARING_WIDTH = 5.0
BALL_DIAMETER = 3.0
BALL_COUNT = 7
RACEWAY_CLEARANCE = 0.05
EDGE_CHAMFER = 0.08
INNER_RING_ANGLE = 35.0
def _axis_part(part_id: str, solid: scad.Solid, name: str) -> scad.Part:
part = scad.make_part_rpart(part_id, solid, name=name)
top_face = max(
solid.get_faces(),
key=lambda face: face.get_center().z if face.get_normal_at().z > 0.7 else -999.0,
)
axis = scad.make_face_connector_rconnector("axis", top_face)
return scad.add_connector_rpart(part, axis)
def _make_demo_shaft() -> scad.Part:
shaft = scad.make_cylinder_rsolid(
radius=BORE_DIAMETER / 2.0 - 0.2,
height=BEARING_WIDTH + 4.0,
bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 4.0),
axis=(0.0, 0.0, 1.0),
)
shaft = scad.apply_tag(shaft, "role.demo_shaft")
return _axis_part("demo_shaft", shaft, "Demo shaft bound to inner ring")
def _make_demo_housing() -> scad.Part:
housing_outer = scad.make_cylinder_rsolid(
radius=OUTER_DIAMETER / 2.0 + 3.0,
height=BEARING_WIDTH + 0.75,
bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 0.75),
axis=(0.0, 0.0, 1.0),
)
bearing_pocket = scad.make_cylinder_rsolid(
radius=OUTER_DIAMETER / 2.0 + 0.25,
height=BEARING_WIDTH + 3.5,
bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 1.75),
axis=(0.0, 0.0, 1.0),
)
housing = scad.cut_rsolid(
housing_outer,
bearing_pocket,
skip_non_intersecting=False,
)
housing = scad.apply_tag(housing, "role.demo_housing")
return _axis_part("demo_housing", housing, "Demo housing bound to outer ring")
def build_ball_bearing_demo():
with scad.GraphSession() as session:
bearing = scad.std.bearing.make_ball_bearing_rassembly(
BORE_DIAMETER,
OUTER_DIAMETER,
BEARING_WIDTH,
BALL_DIAMETER,
BALL_COUNT,
RACEWAY_CLEARANCE,
EDGE_CHAMFER,
"ball_bearing_608_demo",
INNER_RING_ANGLE,
)
meta = bearing.get_metadata("std.bearing.ball_bearing")
outer_ring = bearing.get_component("outer_ring").item.body
inner_ring = bearing.get_component("inner_ring").item.body
print(
"bearing_core",
f"components={len(bearing.component_ids())}",
f"balls={meta['ball_count']}",
f"constraint={meta['revolute_constraint_id']}",
)
print(
"ring_geometry",
f"outer_faces={len(ql.faces().resolve(outer_ring))}",
f"inner_faces={len(ql.faces().resolve(inner_ring))}",
f"outer_volume={outer_ring.get_volume():.2f}",
f"inner_volume={inner_ring.get_volume():.2f}",
)
bearing = scad.add_component_rassembly(
bearing,
_make_demo_shaft(),
component_id="demo_shaft",
placement=scad.identity_placement_rplacement(),
)
bearing = scad.add_component_rassembly(
bearing,
_make_demo_housing(),
component_id="demo_housing",
placement=scad.identity_placement_rplacement(),
)
bearing = scad.add_fixed_constraint_rassembly(
bearing,
"shaft_to_inner_ring",
scad.make_connector_ref_rconnectorref("inner_ring", "axis"),
scad.make_connector_ref_rconnectorref("demo_shaft", "axis"),
)
bearing = scad.add_fixed_constraint_rassembly(
bearing,
"housing_to_outer_ring",
scad.make_connector_ref_rconnectorref("outer_ring", "axis"),
scad.make_connector_ref_rconnectorref("demo_housing", "axis"),
)
bearing = scad.solve_assembly_constraints_rassembly(bearing)
report = scad.inspect_assembly_constraints_rconstraintreport(bearing)
preview = scad.make_compound_from_assembly_rcompound(bearing)
model_json = scad.export_model_json(session)
return bearing, report, preview, model_json
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
assembly, report, preview, model_json = build_ball_bearing_demo()
model_path = OUT_DIR / "ball_bearing_608_demo.model.json"
step_path = OUT_DIR / "ball_bearing_608_demo.step"
fcstd_path = OUT_DIR / "ball_bearing_608_demo.FCStd"
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(preview, str(step_path))
fcstd_status = str(fcstd_path)
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__})"
payload = json.loads(model_json)
print("assembly", assembly.assembly_id)
print("components", assembly.component_ids())
print("constraints", assembly.constraint_ids())
print("solved", report.solved)
print("preview_solids", len(preview.get_solids()))
print("preview_volume", round(preview.get_volume(), 2))
print("graph_nodes", len(payload["graph"]["nodes"]))
print("wrote", model_path)
print("wrote", step_path)
print("fcstd", fcstd_status)
if __name__ == "__main__":
main()
@@ -0,0 +1,101 @@
"""Example 15: export an internal cached mesh as OBJ.
Run from the repository root with:
uv run python examples/15_cached_mesh_obj_export.py
This is a developer-facing example for the mesh-cache groundwork. It
intentionally does not call the public STL exporter. Instead it builds a normal
SimpleCAD solid, reads the framework's internal cached mesh, and writes a common
Wavefront OBJ mesh file from that pure triangle data.
Application code should not depend on ``simplecadapi._mesh``. Future structural
checking APIs will consume the same internal mesh cache without exposing mesh
extraction to framework users.
"""
from __future__ import annotations
from pathlib import Path
import simplecadapi as scad
import simplecadapi._mesh as internal_mesh
OUT_DIR = Path("examples/out/cached_mesh_obj_export")
def build_demo_solid() -> scad.Solid:
"""Build a small bracket-like solid using only normal modeling APIs."""
base = scad.make_box_rsolid(
width=34.0,
height=20.0,
depth=6.0,
bottom_face_center=(0.0, 0.0, 0.0),
)
through_hole = scad.make_cylinder_rsolid(
radius=4.0,
height=12.0,
bottom_face_center=(0.0, 0.0, -3.0),
axis=(0.0, 0.0, 1.0),
)
mount_slot = scad.make_box_rsolid(
width=8.0,
height=24.0,
depth=10.0,
bottom_face_center=(10.0, 0.0, -2.0),
)
boss = scad.make_cylinder_rsolid(
radius=7.0,
height=5.0,
bottom_face_center=(-10.0, 0.0, 6.0),
axis=(0.0, 0.0, 1.0),
)
bracket = scad.cut_rsolid(
base,
through_hole,
mount_slot,
skip_non_intersecting=False,
)
bracket = scad.union_rsolid([bracket, boss])
return scad.apply_tag(shape=bracket, tag="role.cached_mesh_obj_demo")
def write_cached_mesh_obj(solid: scad.Solid, path: Path) -> internal_mesh.TriMesh:
"""Write a Solid's internal cached mesh to Wavefront OBJ."""
mesh = internal_mesh.cached_mesh(solid)
if mesh is None:
detail = internal_mesh.mesh_error(solid) or "no internal mesh cache"
raise RuntimeError(f"Solid has no cached mesh: {detail}")
lines = [
"# OBJ written from SimpleCAD internal cached mesh",
"# This example intentionally bypasses scad.export_stl(...).",
]
for x, y, z in mesh.vertices:
lines.append(f"v {x:.9g} {y:.9g} {z:.9g}")
for a, b, c in mesh.triangles:
lines.append(f"f {int(a) + 1} {int(b) + 1} {int(c) + 1}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return mesh
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
solid = build_demo_solid()
obj_path = OUT_DIR / "cached_mesh_bracket.obj"
mesh = write_cached_mesh_obj(solid=solid, path=obj_path)
lower, upper = mesh.bounds
print("volume", round(solid.get_volume(), 3))
print("faces", len(solid.get_faces()))
print("mesh", f"vertices={mesh.vertex_count}", f"triangles={mesh.triangle_count}")
print("bounds", f"min={tuple(round(v, 3) for v in lower)}", f"max={tuple(round(v, 3) for v in upper)}")
print("wrote_obj", obj_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,49 @@
# Two-Stage Herringbone Planetary Reducer Design
## Part Analysis
- Product: compact coaxial two-stage planetary reducer with input and output flanges.
- Envelope target: maximum outside diameter `50 mm`, total height `30 mm` along the reducer axis.
- Power path: input flange -> input shaft -> stage 1 sun -> stage 1 carrier/intermediate shaft -> stage 2 sun -> stage 2 carrier/output shaft -> output flange.
- Gear type: all active mesh gears are herringbone gears to cancel axial thrust and keep the stacked reducer compact.
- Standard parts: herringbone gears, herringbone ring gears, and radial ball bearings come from `simplecadapi.std.gear` and `simplecadapi.std.bearing`.
- Custom solids: housing sleeve, shafts, carriers, and flanges are integrated SimpleCAD solids built from cylinders, boxes, booleans, and tags.
## Structure Analysis
- Stage 1 uses `S1=12`, `P1=18`, `R1=48` teeth, giving fixed-ring planetary reduction `1 + R1 / S1 = 5:1`.
- Stage 2 uses `S2=12`, `P2=12`, `R2=36` teeth, giving fixed-ring planetary reduction `1 + R2 / S2 = 4:1`.
- Total reduction is `5 * 4 = 20:1`.
- Module is `0.75 mm`; stage 1 ring pitch diameter is `36.0 mm` and stage 2 ring pitch diameter is `27.0 mm`.
- Ring rim thickness is `1.90 mm`, keeping the ring outside radii below the `25 mm` product radius limit.
- Gear stack heights are `4.6 mm` per stage, with carrier plates above each gear plane and flanges at the two axial ends.
- The housing is a 50 mm OD sleeve with a large axial clearance bore and connector faces for all coaxial constraints.
## Bearing Analysis
- Input shaft bearing: one radial ball bearing near the input flange.
- Intermediate shaft bearing: one radial ball bearing between stages to locate the stage 1 carrier / stage 2 sun drive shaft.
- Output shaft bearing: one radial ball bearing near the output flange.
- Stage 1 planet bearings: three radial ball bearing placements centered inside the stage 1 planet gears.
- Stage 2 planet bearings: three radial ball bearing placements centered inside the stage 2 planet gears.
- A single reusable `3.2 x 6.6 x 2.0 mm` bearing standard assembly is instanced at all nine friction locations. This keeps the graph replay stable while still using the standard bearing library.
- Bearing assemblies are only placed for location and packaging. Their internal standard-library revolute detail remains visual; no extra reducer-level bearing rotation constraints are added.
## Assembly Plan
- Ground the outer housing and fix both internal ring gears to the housing axis.
- Add revolute constraints for the input shaft, stage 1 carrier, stage 2 carrier, and all six planet axes.
- Fix the stage 1 sun to the input shaft and input flange.
- Fix the stage 2 sun to the stage 1 carrier intermediate shaft.
- Fix the output flange to the stage 2 carrier/output shaft.
- Add external gear constraints from each sun to its planets using `add_gear_constraint_rassembly`.
- Add internal ring-to-planet mesh constraints using same-direction `add_belt_constraint_rassembly` with ring and planet pitch radii.
- Use `GraphSession`, `export_session_json`, `export_model_json`, `import_model_json`, and `replay_model_json` in the build script for replayable output.
- Ground every build step with concise QL-backed prints: part face counts, volumes, tags, bearing component counts, gear radii, constraint residuals, replay counts, and exported file paths.
## Validation Assumptions
- The envelope check uses analytical constants: outside diameter `50 mm`, axial span `30 mm`.
- Tooth phasing is visual: each planet instance gets an angular placement so a tooth space is roughly aimed at the sun contact line.
- Gear kinematics are represented by assembly constraints; the static CAD preview remains a positioned assembly, not a dynamic simulation.
- If a boolean union needs one merged part, carrier and shaft cylinders overlap their plates and pads rather than merely touching.
@@ -0,0 +1,419 @@
"""Top-level compact two-stage planetary reducer assembly."""
from __future__ import annotations
import simplecadapi as scad
from bearings import (
make_coaxial_bearing_rplacement,
make_planet_bearing_rplacements,
make_radial_ball_bearing_rassembly,
)
from carriers import make_stage_carrier_rpart
from dimensions import (
INPUT_BEARING_Z,
INTERMEDIATE_BEARING_Z,
OUTPUT_BEARING_Z,
PLANET_COUNT,
STAGE1_PLANET_BEARING,
STAGE2_PLANET_BEARING,
STAGE_1,
STAGE_2,
TOTAL_REDUCTION,
StageSpec,
UNIVERSAL_RADIAL_BEARING,
)
from flanges import make_input_flange_rpart, make_output_flange_rpart
from gears import (
make_planet_component_rplacement,
make_stage_planet_gear_rpart,
make_stage_ring_gear_rpart,
make_stage_sun_gear_rpart,
)
from housing import make_reducer_housing_rpart
from materials import make_reducer_materials_rdict
from shafts import make_input_shaft_rpart
def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly:
"""Build the full 20:1 compact reducer assembly and solve constraints."""
print(
f"ratio_plan: stage1={STAGE_1.fixed_ring_ratio:.1f}:1 "
f"stage2={STAGE_2.fixed_ring_ratio:.1f}:1 total={TOTAL_REDUCTION:.1f}:1"
)
materials = make_reducer_materials_rdict()
housing = make_reducer_housing_rpart(material=materials["housing"])
input_flange = make_input_flange_rpart(material=materials["shaft"])
output_flange = make_output_flange_rpart(material=materials["shaft"])
input_shaft = make_input_shaft_rpart(material=materials["shaft"])
stage1_ring = make_stage_ring_gear_rpart(stage=STAGE_1, material=materials["gear"])
stage1_sun = make_stage_sun_gear_rpart(
stage=STAGE_1,
bore_radius=1.56,
material=materials["gear"],
)
stage1_planet = make_stage_planet_gear_rpart(
stage=STAGE_1,
bearing=STAGE1_PLANET_BEARING,
material=materials["gear"],
)
stage1_carrier = make_stage_carrier_rpart(
stage=STAGE_1,
material=materials["carrier"],
)
stage2_ring = make_stage_ring_gear_rpart(stage=STAGE_2, material=materials["gear"])
stage2_sun = make_stage_sun_gear_rpart(
stage=STAGE_2,
bore_radius=1.43,
material=materials["gear"],
)
stage2_planet = make_stage_planet_gear_rpart(
stage=STAGE_2,
bearing=STAGE2_PLANET_BEARING,
material=materials["gear"],
)
stage2_carrier = make_stage_carrier_rpart(
stage=STAGE_2,
material=materials["carrier"],
)
bearing = make_radial_ball_bearing_rassembly(
bearing_id="micro_radial_ball_bearing",
spec=UNIVERSAL_RADIAL_BEARING,
)
reducer = scad.make_assembly_rassembly(
assembly_id="compact_two_stage_planetary_reducer",
name="58.8 mm OD 20:1 through-bolted herringbone planetary actuator reducer",
)
reducer = _add_fixed_components_rassembly(
assembly=reducer,
components=(
("housing", housing, scad.identity_placement_rplacement(), "Fixed outer housing"),
("input_flange", input_flange, scad.identity_placement_rplacement(), "Rotating input flange"),
("output_flange", output_flange, scad.identity_placement_rplacement(), "Rotating output flange"),
("input_shaft", input_shaft, scad.identity_placement_rplacement(), "Input shaft"),
("stage1_carrier", stage1_carrier, scad.identity_placement_rplacement(), "Stage 1 carrier and stage 2 sun shaft"),
("stage2_carrier", stage2_carrier, scad.identity_placement_rplacement(), "Stage 2 carrier and output shaft"),
("stage1_ring", stage1_ring, _gear_stage_rplacement(stage=STAGE_1), "Stage 1 fixed ring"),
("stage1_sun", stage1_sun, _gear_stage_rplacement(stage=STAGE_1), "Stage 1 sun"),
("stage2_ring", stage2_ring, _gear_stage_rplacement(stage=STAGE_2), "Stage 2 fixed ring"),
("stage2_sun", stage2_sun, _gear_stage_rplacement(stage=STAGE_2), "Stage 2 sun"),
),
)
for index in range(PLANET_COUNT):
reducer = scad.add_component_rassembly(
assembly=reducer,
item=stage1_planet,
component_id=f"stage1_planet_{index + 1}",
placement=make_planet_component_rplacement(stage=STAGE_1, planet_index=index),
name=f"Stage 1 planet gear {index + 1}",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=stage2_planet,
component_id=f"stage2_planet_{index + 1}",
placement=make_planet_component_rplacement(stage=STAGE_2, planet_index=index),
name=f"Stage 2 planet gear {index + 1}",
)
reducer = _add_bearing_components_rassembly(
assembly=reducer,
bearing=bearing,
)
reducer = _add_public_interface_connectors_rassembly(assembly=reducer)
reducer = _add_reducer_constraints_rassembly(assembly=reducer)
reducer = scad.solve_assembly_constraints_rassembly(assembly=reducer, strict=True)
_ground_constraint_report(assembly=reducer)
return reducer
def _add_fixed_components_rassembly(
*,
assembly: scad.Assembly,
components: tuple[tuple[str, scad.Part, scad.Placement, str], ...],
) -> scad.Assembly:
for component_id, item, placement, name in components:
assembly = scad.add_component_rassembly(
assembly=assembly,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
print(f"base_components: count={len(components)}")
return assembly
def _add_bearing_components_rassembly(
*,
assembly: scad.Assembly,
bearing: scad.Assembly,
) -> scad.Assembly:
bearing_component_count = 0
for component_id, bearing, placement, name in (
(
"input_bearing",
bearing,
make_coaxial_bearing_rplacement(z=INPUT_BEARING_Z),
"Input shaft radial ball bearing",
),
(
"intermediate_bearing",
bearing,
make_coaxial_bearing_rplacement(z=INTERMEDIATE_BEARING_Z),
"Intermediate shaft radial ball bearing",
),
(
"output_bearing",
bearing,
make_coaxial_bearing_rplacement(z=OUTPUT_BEARING_Z),
"Output shaft radial ball bearing",
),
):
assembly = scad.add_component_rassembly(
assembly=assembly,
item=bearing,
component_id=component_id,
placement=placement,
name=name,
)
bearing_component_count += 1
for index, placement in enumerate(make_planet_bearing_rplacements(stage=STAGE_1)):
component_id = f"stage1_planet_bearing_{index + 1}"
assembly = scad.add_component_rassembly(
assembly=assembly,
item=bearing,
component_id=component_id,
placement=placement,
name=f"Stage 1 planet {index + 1} ball bearing",
)
bearing_component_count += 1
for index, placement in enumerate(make_planet_bearing_rplacements(stage=STAGE_2)):
component_id = f"stage2_planet_bearing_{index + 1}"
assembly = scad.add_component_rassembly(
assembly=assembly,
item=bearing,
component_id=component_id,
placement=placement,
name=f"Stage 2 planet {index + 1} ball bearing",
)
bearing_component_count += 1
print(f"bearing_components: count={bearing_component_count} grounded=0")
return assembly
def _add_public_interface_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
"""Expose stable actuator module datums without leaking private component ids."""
forwarded = (
("housing_mount_axis", "housing", "output_axis", "Fixed case mounting datum"),
("input_motor_axis", "input_flange", "axis", "Input flange datum for motor can"),
("output_link_axis", "output_flange", "axis", "Output flange datum for driven link"),
)
for connector_id, source_component_id, source_connector_id, name in forwarded:
assembly = scad.forward_connector_rassembly(
assembly=assembly,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
)
print("reducer_public_connectors: " + ",".join(connector_id for connector_id, *_ in forwarded))
return assembly
def _add_reducer_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="housing")
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage1_ring")
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage2_ring")
fixed_pairs = (
("stage1_ring_fixed", "housing", "stage1_axis", "stage1_ring", "axis"),
("stage2_ring_fixed", "housing", "stage2_axis", "stage2_ring", "axis"),
("input_flange_to_shaft", "input_flange", "axis", "input_shaft", "flange_axis"),
("stage1_sun_to_input_shaft", "input_shaft", "sun_axis", "stage1_sun", "axis"),
("stage2_sun_to_stage1_carrier", "stage1_carrier", "stage2_sun_axis", "stage2_sun", "axis"),
("output_flange_to_stage2_carrier", "stage2_carrier", "output_axis", "output_flange", "axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in fixed_pairs:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=_ref(component_id=a_component, connector_id=a_connector),
connector_b=_ref(component_id=b_component, connector_id=b_connector),
name=constraint_id.replace("_", " "),
)
revolutes = (
("input_shaft_revolute", "housing", "input_axis", "input_shaft", "sun_axis"),
("stage1_carrier_revolute", "housing", "stage2_axis", "stage1_carrier", "carrier_axis"),
("stage2_carrier_revolute", "housing", "output_axis", "stage2_carrier", "carrier_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in revolutes:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=_ref(component_id=a_component, connector_id=a_connector),
connector_b=_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=0.0,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
assembly = _add_stage_mesh_constraints_rassembly(
assembly=assembly,
stage=STAGE_1,
driver_component_id="input_shaft",
driver_connector_id="sun_axis",
ring_component_id="stage1_ring",
carrier_component_id="stage1_carrier",
)
assembly = _add_stage_mesh_constraints_rassembly(
assembly=assembly,
stage=STAGE_2,
driver_component_id="stage1_carrier",
driver_connector_id="carrier_axis",
ring_component_id="stage2_ring",
carrier_component_id="stage2_carrier",
)
assembly = _add_bearing_interface_constraints_rassembly(assembly=assembly)
print("constraints_added: fixed=6 revolute=27 gear_mesh=6 internal_mesh=6")
return assembly
def _add_bearing_interface_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
coaxial_interfaces = (
("input_bearing_outer_to_housing", "housing", "input_bearing_axis", "input_bearing", "outer_axis"),
("input_bearing_inner_to_shaft", "input_shaft", "input_bearing_axis", "input_bearing", "inner_axis"),
("intermediate_bearing_outer_to_housing", "housing", "intermediate_bearing_axis", "intermediate_bearing", "outer_axis"),
("intermediate_bearing_inner_to_stage1_carrier", "stage1_carrier", "intermediate_bearing_axis", "intermediate_bearing", "inner_axis"),
("output_bearing_outer_to_housing", "housing", "output_bearing_axis", "output_bearing", "outer_axis"),
("output_bearing_inner_to_stage2_carrier", "stage2_carrier", "output_bearing_axis", "output_bearing", "inner_axis"),
)
for constraint_id, a_component, a_connector, bearing_component, bearing_connector in coaxial_interfaces:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=_ref(component_id=a_component, connector_id=a_connector),
connector_b=_ref(component_id=bearing_component, connector_id=bearing_connector),
drive_angle_degrees=None,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
for stage, carrier_component_id in ((STAGE_1, "stage1_carrier"), (STAGE_2, "stage2_carrier")):
for index in range(PLANET_COUNT):
planet_id = f"{stage.stage_id}_planet_{index + 1}"
bearing_id = f"{stage.stage_id}_planet_bearing_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{bearing_id}_outer_to_planet",
connector_a=_ref(component_id=planet_id, connector_id="bearing_axis"),
connector_b=_ref(component_id=bearing_id, connector_id="outer_axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{bearing_id} outer ring to planet gear bore",
)
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{bearing_id}_inner_to_carrier_pin",
connector_a=_ref(
component_id=carrier_component_id,
connector_id=f"planet_{index + 1}_bearing_axis",
),
connector_b=_ref(component_id=bearing_id, connector_id="inner_axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{bearing_id} inner ring to carrier pin",
)
return assembly
def _add_stage_mesh_constraints_rassembly(
*,
assembly: scad.Assembly,
stage: StageSpec,
driver_component_id: str,
driver_connector_id: str,
ring_component_id: str,
carrier_component_id: str,
) -> scad.Assembly:
for index in range(PLANET_COUNT):
planet_component_id = f"{stage.stage_id}_planet_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{stage.stage_id}_planet_{index + 1}_revolute",
connector_a=_ref(
component_id=carrier_component_id,
connector_id=f"planet_{index + 1}_axis",
),
connector_b=_ref(component_id=planet_component_id, connector_id="axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{stage.label} planet {index + 1} pin bearing axis",
)
assembly = scad.add_gear_constraint_rassembly(
assembly=assembly,
constraint_id=f"{stage.stage_id}_sun_planet_{index + 1}_external_mesh",
connector_a=_ref(component_id=driver_component_id, connector_id=driver_connector_id),
connector_b=_ref(component_id=planet_component_id, connector_id="axis"),
pitch_radius_a=stage.sun_pitch_radius,
pitch_radius_b=stage.planet_pitch_radius,
phase_offset=None,
name=f"{stage.label} external sun to planet {index + 1} mesh",
)
assembly = scad.add_belt_constraint_rassembly(
assembly=assembly,
constraint_id=f"{stage.stage_id}_ring_planet_{index + 1}_internal_mesh",
connector_a=_ref(component_id=ring_component_id, connector_id="axis"),
connector_b=_ref(component_id=planet_component_id, connector_id="axis"),
pulley_radius_a=stage.ring_pitch_radius,
pulley_radius_b=stage.planet_pitch_radius,
phase_offset=None,
name=f"{stage.label} internal fixed-ring to planet {index + 1} mesh",
)
print(
f"{stage.stage_id}_constraints: sun_r={stage.sun_pitch_radius:.3f} "
f"planet_r={stage.planet_pitch_radius:.3f} ring_r={stage.ring_pitch_radius:.3f}"
)
return assembly
def _gear_stage_rplacement(*, stage: StageSpec) -> scad.Placement:
return scad.make_placement_rplacement(
origin=(0.0, 0.0, stage.bottom_z),
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 1.0, 0.0),
)
def _ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
def _ground_constraint_report(*, assembly: scad.Assembly) -> None:
report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly)
print(
f"assembly_constraints: solved={report.solved} components={len(assembly.component_ids())} "
f"constraints={len(assembly.constraints)} grounded={len(assembly.grounded_component_ids)}"
)
for residual in report.residuals:
print(
f"constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}"
)
STAGE1_PLANET_BEARING,
STAGE2_PLANET_BEARING,
UNIVERSAL_RADIAL_BEARING,
@@ -0,0 +1,67 @@
"""Standard ball bearing assemblies placed in the reducer."""
from __future__ import annotations
import math
import simplecadapi as scad
from common import make_z_rotation_rplacement
from dimensions import BearingSpec, PLANET_COUNT, StageSpec
def make_radial_ball_bearing_rassembly(
*,
bearing_id: str,
spec: BearingSpec,
) -> scad.Assembly:
"""Create a standard radial ball bearing assembly for reducer placement."""
bearing = scad.std.bearing.make_ball_bearing_rassembly(
bore_diameter=spec.bore_diameter,
outer_diameter=spec.outer_diameter,
bearing_width=spec.width,
ball_diameter=spec.ball_diameter,
ball_count=spec.ball_count,
raceway_clearance=spec.raceway_clearance,
edge_chamfer=spec.edge_chamfer,
assembly_id=bearing_id,
drive_angle_degrees=None,
)
meta = bearing.get_metadata("std.bearing.ball_bearing")
outer_ring = bearing.get_component("outer_ring").item.body
inner_ring = bearing.get_component("inner_ring").item.body
print(
f"bearing_{bearing_id}: components={len(bearing.component_ids())} balls={meta['ball_count']} "
f"od={spec.outer_diameter:.2f} bore={spec.bore_diameter:.2f} width={spec.width:.2f}"
)
print(
f"bearing_{bearing_id}_rings: outer_faces={len(outer_ring.get_faces())} "
f"inner_faces={len(inner_ring.get_faces())}"
)
return bearing
def make_coaxial_bearing_rplacement(*, z: float) -> scad.Placement:
"""Return a coaxial bearing placement at the requested axial center."""
return make_z_rotation_rplacement(origin=(0.0, 0.0, z), angle_degrees=0.0)
def make_planet_bearing_rplacements(*, stage: StageSpec) -> list[scad.Placement]:
"""Return placed bearing placements centered in all planets of one stage."""
placements = []
for index in range(PLANET_COUNT):
angle = math.radians(360.0 * index / PLANET_COUNT)
center = (
stage.planet_center_radius * math.cos(angle),
stage.planet_center_radius * math.sin(angle),
stage.mid_z,
)
placements.append(make_z_rotation_rplacement(origin=center, angle_degrees=0.0))
print(
f"{stage.stage_id}_planet_bearing_{index + 1}: "
f"center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f})"
)
return placements
@@ -0,0 +1,251 @@
"""Carrier plates, planet pins, and coaxial output shafts."""
from __future__ import annotations
import math
import simplecadapi as scad
from common import _apply_tags, add_placement_axis_connector_rpart, make_axis_part_rpart
from dimensions import (
INTERMEDIATE_BEARING_Z,
OUTPUT_FLANGE_TOP_Z,
OUTPUT_BEARING_Z,
OUTPUT_SHAFT_RADIUS,
PLANET_COUNT,
STAGE1_ARM_WIDTH,
STAGE1_CARRIER_PLATE_BOTTOM_Z,
STAGE1_CARRIER_PLATE_THICKNESS,
STAGE1_CARRIER_SHAFT_RADIUS,
STAGE1_HUB_RADIUS,
STAGE1_PAD_RADIUS,
STAGE1_PIN_BOTTOM_Z,
STAGE1_PIN_LAND_RADIUS,
STAGE1_PIN_RADIUS,
STAGE2_ARM_WIDTH,
STAGE2_CARRIER_PLATE_BOTTOM_Z,
STAGE2_CARRIER_PLATE_THICKNESS,
STAGE2_HUB_RADIUS,
STAGE2_PAD_RADIUS,
STAGE2_PIN_BOTTOM_Z,
STAGE2_PIN_LAND_RADIUS,
STAGE2_PIN_RADIUS,
STAGE_2,
StageSpec,
)
def make_stage_carrier_rpart(
*,
stage: StageSpec,
material: scad.Material,
) -> scad.Part:
"""Create one carrier with three planet pins and any coaxial drive shaft."""
if stage.stage_id == "stage1":
solid = _make_carrier_solid_rsolid(
stage=stage,
plate_bottom_z=STAGE1_CARRIER_PLATE_BOTTOM_Z,
plate_thickness=STAGE1_CARRIER_PLATE_THICKNESS,
pin_bottom_z=STAGE1_PIN_BOTTOM_Z,
pin_radius=STAGE1_PIN_RADIUS,
pin_land_radius=STAGE1_PIN_LAND_RADIUS,
hub_radius=STAGE1_HUB_RADIUS,
arm_width=STAGE1_ARM_WIDTH,
pad_radius=STAGE1_PAD_RADIUS,
central_shaft_radius=STAGE1_CARRIER_SHAFT_RADIUS,
central_shaft_top_z=STAGE_2.top_z,
)
connector_specs = [
{
"connector_id": "carrier_axis",
"center_xy": (0.0, 0.0),
"target_z": STAGE_2.top_z,
"normal_z": 1.0,
},
{
"connector_id": "stage2_sun_axis",
"center_xy": (0.0, 0.0),
"target_z": STAGE_2.top_z,
"normal_z": 1.0,
},
]
elif stage.stage_id == "stage2":
solid = _make_carrier_solid_rsolid(
stage=stage,
plate_bottom_z=STAGE2_CARRIER_PLATE_BOTTOM_Z,
plate_thickness=STAGE2_CARRIER_PLATE_THICKNESS,
pin_bottom_z=STAGE2_PIN_BOTTOM_Z,
pin_radius=STAGE2_PIN_RADIUS,
pin_land_radius=STAGE2_PIN_LAND_RADIUS,
hub_radius=STAGE2_HUB_RADIUS,
arm_width=STAGE2_ARM_WIDTH,
pad_radius=STAGE2_PAD_RADIUS,
central_shaft_radius=OUTPUT_SHAFT_RADIUS,
central_shaft_top_z=OUTPUT_FLANGE_TOP_Z,
)
connector_specs = [
{
"connector_id": "carrier_axis",
"center_xy": (0.0, 0.0),
"target_z": OUTPUT_FLANGE_TOP_Z,
"normal_z": 1.0,
},
{
"connector_id": "output_axis",
"center_xy": (0.0, 0.0),
"target_z": OUTPUT_FLANGE_TOP_Z,
"normal_z": 1.0,
},
]
else:
raise ValueError(f"unsupported carrier stage: {stage.stage_id}")
for index in range(PLANET_COUNT):
connector_specs.append(
{
"connector_id": f"planet_{index + 1}_axis",
"center_xy": _planet_center(stage=stage, planet_index=index),
"target_z": stage.top_z,
"normal_z": 1.0,
}
)
part = make_axis_part_rpart(
part_id=f"{stage.stage_id}_carrier",
solid=solid,
name=f"{stage.label} carrier with planet pins",
material=material,
connector_specs=connector_specs,
)
if stage.stage_id == "stage1":
part = add_placement_axis_connector_rpart(
part=part,
connector_id="intermediate_bearing_axis",
origin=(0.0, 0.0, INTERMEDIATE_BEARING_Z),
name="Intermediate bearing shaft seat axis",
)
else:
part = add_placement_axis_connector_rpart(
part=part,
connector_id="output_bearing_axis",
origin=(0.0, 0.0, OUTPUT_BEARING_Z),
name="Output bearing shaft seat axis",
)
for index in range(PLANET_COUNT):
part = add_placement_axis_connector_rpart(
part=part,
connector_id=f"planet_{index + 1}_bearing_axis",
origin=(*_planet_center(stage=stage, planet_index=index), stage.mid_z),
name=f"{stage.label} planet {index + 1} bearing pin axis",
)
return part
def _make_carrier_solid_rsolid(
*,
stage: StageSpec,
plate_bottom_z: float,
plate_thickness: float,
pin_bottom_z: float,
pin_radius: float,
pin_land_radius: float,
hub_radius: float,
arm_width: float,
pad_radius: float,
central_shaft_radius: float,
central_shaft_top_z: float,
) -> scad.Solid:
hub = scad.make_cylinder_rsolid(
radius=hub_radius,
height=plate_thickness,
bottom_face_center=(0.0, 0.0, plate_bottom_z),
axis=(0.0, 0.0, 1.0),
)
solids = [hub]
shaft_bottom_z = plate_bottom_z - 0.05
solids.append(
scad.make_cylinder_rsolid(
radius=central_shaft_radius,
height=central_shaft_top_z - shaft_bottom_z,
bottom_face_center=(0.0, 0.0, shaft_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
# Do not let the carrier arms merely kiss the hub at a shallow overlap.
# The previous 0.35 mm embed was enough for OCC to return one Solid, but two
# rotated arms could still read visually like separate fork prongs in export
# views. A real carrier web should grow well into the center hub so torque is
# carried through material, not through a tangent-looking boolean seam.
arm_inner_radius = max(central_shaft_radius + 0.25, hub_radius - 1.25)
arm_outer_radius = stage.planet_center_radius + pad_radius - 0.25
arm_length = arm_outer_radius - arm_inner_radius
arm_center_radius = (arm_inner_radius + arm_outer_radius) / 2.0
pin_height = plate_bottom_z + plate_thickness - pin_bottom_z
pin_land_height = stage.top_z - pin_bottom_z
for index in range(PLANET_COUNT):
carrier_angle = 360.0 * index / PLANET_COUNT
center_xy = _planet_center(stage=stage, planet_index=index)
arm = scad.make_box_rsolid(
width=arm_length,
height=arm_width,
depth=plate_thickness,
bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z),
)
if abs(carrier_angle) > 1.0e-9:
arm = scad.rotate_shape(
shape=arm,
angle=carrier_angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
solids.append(arm)
solids.append(
scad.make_cylinder_rsolid(
radius=pad_radius,
height=plate_thickness,
bottom_face_center=(center_xy[0], center_xy[1], plate_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=pin_radius,
height=pin_height,
bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=pin_land_radius,
height=pin_land_height,
bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
carrier = scad.union_rsolid(solids, glue=False)
carrier = _apply_tags(
carrier,
tags=(f"role.{stage.stage_id}.planet_carrier", "group.two_stage_reducer"),
)
print(
f"{stage.stage_id}_carrier_geometry: center_radius={stage.planet_center_radius:.3f} "
f"arm_length={arm_length:.3f} arm_hub_embed={hub_radius - arm_inner_radius:.3f} "
f"pin_height={pin_height:.3f} shaft_top={central_shaft_top_z:.3f} "
f"faces={len(carrier.get_faces())} volume={carrier.get_volume():.3f}"
)
return carrier
def _planet_center(*, stage: StageSpec, planet_index: int) -> tuple[float, float]:
angle = math.radians(360.0 * planet_index / PLANET_COUNT)
return (
stage.planet_center_radius * math.cos(angle),
stage.planet_center_radius * math.sin(angle),
)
@@ -0,0 +1,81 @@
"""Run static collision verification on the compact reducer example.
Run from the repository root with:
uv run python examples/16_compact_two_stage_planetary_reducer/collision_probe.py
This probe builds the solved reducer assembly without exporting STEP/FCStd, then
checks the current pose with ``scad.verifier.check_collision_rcollisionreport``.
The current verifier uses FCL-reported mesh contact penetration only; it does
not handle complete containment cases.
"""
from __future__ import annotations
import contextlib
import io
import sys
import time
import simplecadapi as scad
from assembly import make_two_stage_planetary_reducer_rassembly
sys.setrecursionlimit(30000)
def _build_reducer_quietly() -> tuple[scad.Assembly, int, float]:
log_buffer = io.StringIO()
start = time.perf_counter()
with contextlib.redirect_stdout(log_buffer):
with scad.GraphSession(graph_id="compact_reducer_collision_probe"):
assembly = make_two_stage_planetary_reducer_rassembly()
elapsed = time.perf_counter() - start
return assembly, len(log_buffer.getvalue().splitlines()), elapsed
def main() -> None:
assembly, log_lines, build_seconds = _build_reducer_quietly()
start = time.perf_counter()
report = scad.verifier.check_collision_rcollisionreport(
assembly=assembly,
config=scad.verifier.CollisionCheckConfig(
max_allowed_penetration=0.02,
max_contacts_per_pair=16,
),
)
check_seconds = time.perf_counter() - start
print("assembly", assembly.assembly_id)
print("build_log_lines", log_lines)
print("build_seconds", round(build_seconds, 3))
print("check_seconds", round(check_seconds, 3))
print("completed", report.completed)
print("passed", report.passed)
print("checked_pair_count", report.checked_pair_count)
print("failed_pair_count", report.failed_pair_count)
print("warning_count", len(report.warnings))
for warning in report.warnings[:20]:
path = "/".join(warning.component_path or ())
print("warning", warning.code, path, warning.message)
for failure in sorted(
report.failures,
key=lambda item: item.penetration_depth,
reverse=True,
)[:20]:
print(
"failure",
"/".join(failure.component_a),
"/".join(failure.component_b),
"depth",
round(failure.penetration_depth, 4),
"contacts",
len(failure.contacts),
)
if __name__ == "__main__":
main()
@@ -0,0 +1,188 @@
"""Shared construction and grounding helpers for the reducer example."""
from __future__ import annotations
import math
from collections.abc import Iterable
import simplecadapi as scad
from simplecadapi import ql
def make_z_rotation_rplacement(
*,
origin: tuple[float, float, float],
angle_degrees: float,
) -> scad.Placement:
"""Return a placement rotated about the local Z axis."""
angle_radians = math.radians(angle_degrees)
cos_a = math.cos(angle_radians)
sin_a = math.sin(angle_radians)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(cos_a, sin_a, 0.0),
y_axis=(-sin_a, cos_a, 0.0),
)
def make_annular_cylinder_rsolid(
*,
outer_radius: float,
inner_radius: float,
height: float,
bottom_z: float,
tag: str,
) -> scad.Solid:
"""Create a single hollow cylindrical solid with a through bore."""
if inner_radius <= 0.0 or outer_radius <= inner_radius:
raise ValueError("annular cylinder requires 0 < inner_radius < outer_radius")
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
annular = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
annular = scad.apply_tag(shape=annular, tag=tag)
_ground_solid(label=tag, solid=annular)
return annular
def make_axis_connector_rconnector(
*,
connector_id: str,
solid: scad.Solid,
center_xy: tuple[float, float],
target_z: float,
normal_z: float,
name: str | None = None,
flip: bool = False,
) -> scad.Connector:
"""Create a face connector on the axial face nearest the requested center."""
face = _axis_face(
label=connector_id,
solid=solid,
center_xy=center_xy,
target_z=target_z,
normal_z=normal_z,
)
return scad.make_face_connector_rconnector(
connector_id=connector_id,
face=face,
name=name,
flip=flip,
)
def make_axis_part_rpart(
*,
part_id: str,
solid: scad.Solid,
name: str,
connector_specs: Iterable[dict[str, object]],
material: scad.Material | None = None,
) -> scad.Part:
"""Wrap a solid as a Part and attach axial face connectors."""
part = scad.make_part_rpart(part_id=part_id, body=solid, name=name)
if material is not None:
part = scad.assign_material_rpart(part=part, material=material)
for spec in connector_specs:
part = scad.add_connector_rpart(
part=part,
connector=make_axis_connector_rconnector(
connector_id=str(spec["connector_id"]),
solid=solid,
center_xy=spec["center_xy"], # type: ignore[arg-type]
target_z=float(spec["target_z"]),
normal_z=float(spec["normal_z"]),
name=spec.get("name"), # type: ignore[arg-type]
flip=bool(spec.get("flip", False)),
),
)
print(f"part_{part_id}: connectors={len(part.connectors)} material={bool(material)}")
return part
def add_placement_axis_connector_rpart(
*,
part: scad.Part,
connector_id: str,
origin: tuple[float, float, float],
name: str | None = None,
) -> scad.Part:
"""Attach a topology-free axis connector at an explicit local placement."""
connector = scad.make_placement_connector_rconnector(
connector_id=connector_id,
placement=scad.make_placement_rplacement(origin=origin),
name=name,
)
return scad.add_connector_rpart(part=part, connector=connector)
def _apply_tags(shape: scad.Solid, tags: Iterable[str]) -> scad.Solid:
"""Apply normalized tags through the public SimpleCAD tag API."""
tagged = shape
for tag in tags:
tagged = scad.apply_tag(shape=tagged, tag=tag)
return tagged
def _axis_face(
*,
label: str,
solid: scad.Solid,
center_xy: tuple[float, float],
target_z: float,
normal_z: float,
) -> scad.Face:
candidates = []
for face in ql.select(items=solid.get_faces()).all():
normal = face.get_normal_at()
if normal_z > 0.0 and normal.z < 0.65:
continue
if normal_z < 0.0 and normal.z > -0.65:
continue
center = face.get_center()
xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1])
z_error = abs(center.z - target_z)
candidates.append((z_error * 1000.0 + xy_error, face, center, normal))
if not candidates:
raise ValueError(f"no axial connector face found for {label}")
_score, face, center, normal = min(candidates, key=lambda item: item[0])
print(
f"connector_{label}: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) "
f"normal=({normal.x:.2f},{normal.y:.2f},{normal.z:.2f}) area={face.get_area():.3f}"
)
return face
def _ground_solid(*, label: str, solid: scad.Solid) -> None:
faces = ql.select(items=solid.get_faces()).all()
role_faces = ql.select(items=faces).where(ql.tag(pattern="role.*")).all()
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
def _ground_compound(*, label: str, compound: scad.Compound) -> None:
"""Print a compact QL-backed summary of an assembly preview compound."""
solids = ql.select(items=compound.get_solids()).all()
face_count = sum(len(ql.select(items=solid.get_faces()).all()) for solid in solids)
volume = sum(solid.get_volume() for solid in solids)
print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.3f}")
@@ -0,0 +1,238 @@
"""Design constants for the compact two-stage planetary reducer."""
from __future__ import annotations
from dataclasses import dataclass
PLANET_COUNT = 3
MODULE = 0.75
PRESSURE_ANGLE = 20.0
HELIX_ANGLE = 27.0
GEAR_HEIGHT = 4.60
ADDENDUM_FACTOR = 1.0
CLEARANCE_FACTOR = 0.25
RING_RIM_THICKNESS = 1.90
FIXED_RING_HOUSING_SUPPORT_OVERLAP = 0.30
BACKLASH = 0.02
HOUSING_OUTER_RADIUS = 29.4
# The original 50 mm reducer envelope did not leave enough radial room for real
# M3-class housing screws outside the ring gear. The practical actuator package
# grows to about 59 mm OD so through-bolt bosses have enough wall around the head
# counterbores instead of being cosmetic pin holes.
HOUSING_BODY_OUTER_RADIUS = 24.2
HOUSING_INNER_RADIUS = 21.70
HOUSING_DATUM_INNER_RADIUS = 20.95
HOUSING_DATUM_OUTER_RADIUS = 21.85
HOUSING_BOTTOM_Z = -15.0
HOUSING_HEIGHT = 30.0
# Output/input end-cap details seen on real joint actuators. The case fasteners
# are modeled as real through holes through the housing stack, not as cosmetic
# front-face pockets; the larger envelope is the cost of giving M3-class screws
# enough boss diameter outside the planetary ring gear.
HOUSING_FRONT_FLANGE_THICKNESS = 3.0
HOUSING_REAR_FLANGE_THICKNESS = 3.0
HOUSING_END_FLANGE_OUTER_RADIUS = 24.2
# The outer mounting structure is four continuous sector pads, each carrying
# three full-length screws. This is closer to the reference actuator than twelve
# isolated round ears: it preserves a scalloped circular silhouette, gives each
# fastener real surrounding material, and leaves service gaps between quarters.
HOUSING_MOUNT_SECTOR_COUNT = 4
HOUSING_MOUNT_HOLES_PER_SECTOR = 3
HOUSING_MOUNT_HOLE_COUNT = HOUSING_MOUNT_SECTOR_COUNT * HOUSING_MOUNT_HOLES_PER_SECTOR
HOUSING_MOUNT_PAD_INNER_RADIUS = 23.6
HOUSING_MOUNT_PAD_OUTER_RADIUS = HOUSING_OUTER_RADIUS
HOUSING_MOUNT_SECTOR_GAP_WIDTH = 5.0
HOUSING_MOUNT_SECTOR_CENTER_OFFSET_DEGREES = 45.0
HOUSING_MOUNT_HOLE_OFFSET_DEGREES = 18.0
HOUSING_MOUNT_HOLE_CIRCLE_RADIUS = 26.4
HOUSING_MOUNT_HOLE_DIAMETER = 3.0
HOUSING_MOUNT_COUNTERBORE_DIAMETER = 5.6
HOUSING_MOUNT_COUNTERBORE_DEPTH = 1.2
# A sealed output needs a small controlled radial gap instead of the previous
# large empty annulus between the rotating flange and fixed housing. The front
# flange bore is the fixed labyrinth lip; it is not a separate part because that
# would create a fully contained boolean feature with no extra assembly value.
OUTPUT_SEAL_BORE_RADIUS = 20.65
OUTPUT_SEAL_RUNNING_CLEARANCE = 0.30
INPUT_SEAL_RUNNING_CLEARANCE = 0.30
INPUT_FLANGE_BOTTOM_Z = -15.0
INPUT_FLANGE_THICKNESS = 2.0
INPUT_FLANGE_BOSS_HEIGHT = 1.0
INPUT_FLANGE_TOP_Z = INPUT_FLANGE_BOTTOM_Z + INPUT_FLANGE_THICKNESS + INPUT_FLANGE_BOSS_HEIGHT
INPUT_FLANGE_OUTER_DIAMETER = 23.0
INPUT_FLANGE_INNER_DIAMETER = 3.0
INPUT_FLANGE_BOSS_OUTER_DIAMETER = 8.0
INPUT_FLANGE_HOLE_DIAMETER = 1.6
INPUT_FLANGE_HOLE_CIRCLE_DIAMETER = 17.0
INPUT_FLANGE_HOLE_COUNT = 6
INPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER = 3.0
INPUT_FLANGE_HOLE_COUNTERBORE_DEPTH = 0.45
INPUT_SEAL_BORE_RADIUS = INPUT_FLANGE_OUTER_DIAMETER / 2.0 + INPUT_SEAL_RUNNING_CLEARANCE
OUTPUT_FLANGE_BOTTOM_Z = 12.0
OUTPUT_FLANGE_THICKNESS = 2.4
OUTPUT_FLANGE_BOSS_HEIGHT = 0.6
OUTPUT_FLANGE_TOP_Z = OUTPUT_FLANGE_BOTTOM_Z + OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT
OUTPUT_FLANGE_OUTER_DIAMETER = 40.7
OUTPUT_FLANGE_INNER_DIAMETER = 3.4
OUTPUT_FLANGE_BOSS_OUTER_DIAMETER = 12.8
OUTPUT_FLANGE_HOLE_DIAMETER = 2.0
OUTPUT_FLANGE_HOLE_CIRCLE_DIAMETER = 30.0
OUTPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER = 3.2
OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH = 0.45
# The raised segmented register is not decorative. Real actuator output flanges
# often use raised islands so the mating link can have matching recesses for fast
# angular indexing and shear load location before the screws are tightened.
OUTPUT_FLANGE_REGISTER_INNER_DIAMETER = 22.0
OUTPUT_FLANGE_REGISTER_OUTER_DIAMETER = 35.0
OUTPUT_FLANGE_REGISTER_HEIGHT = 0.45
OUTPUT_FLANGE_REGISTER_PAD_COUNT = 3
OUTPUT_FLANGE_REGISTER_GAP_WIDTH = 3.1
OUTPUT_FLANGE_HOLES_PER_PAD = 2
OUTPUT_FLANGE_HOLE_OFFSET_DEGREES = 18.0
OUTPUT_FLANGE_HOLE_COUNT = OUTPUT_FLANGE_REGISTER_PAD_COUNT * OUTPUT_FLANGE_HOLES_PER_PAD
OUTPUT_FLANGE_CENTER_FASTENER_COUNT = 3
OUTPUT_FLANGE_CENTER_FASTENER_CIRCLE_DIAMETER = 10.5
OUTPUT_FLANGE_CENTER_FASTENER_DIAMETER = 1.2
OUTPUT_FLANGE_CENTER_COUNTERBORE_DIAMETER = 2.1
OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH = 0.35
INPUT_SHAFT_RADIUS = 1.45
STAGE1_CARRIER_SHAFT_RADIUS = 1.35
OUTPUT_SHAFT_RADIUS = 1.50
STAGE1_CARRIER_PLATE_BOTTOM_Z = -3.25
STAGE1_CARRIER_PLATE_THICKNESS = 1.65
STAGE1_PIN_BOTTOM_Z = -8.15
STAGE1_PIN_RADIUS = 1.10
STAGE1_PIN_LAND_RADIUS = 1.18
STAGE1_HUB_RADIUS = 3.40
STAGE1_ARM_WIDTH = 2.50
STAGE1_PAD_RADIUS = 4.10
STAGE2_CARRIER_PLATE_BOTTOM_Z = 6.45
STAGE2_CARRIER_PLATE_THICKNESS = 1.65
STAGE2_PIN_BOTTOM_Z = 1.45
STAGE2_PIN_RADIUS = 0.82
STAGE2_PIN_LAND_RADIUS = 0.93
STAGE2_HUB_RADIUS = 3.35
STAGE2_ARM_WIDTH = 2.35
STAGE2_PAD_RADIUS = 3.20
INPUT_BEARING_Z = -11.0
INTERMEDIATE_BEARING_Z = 0.0
OUTPUT_BEARING_Z = 10.8
@dataclass(frozen=True)
class StageSpec:
"""A tooth-count and axial-location spec for one planetary stage."""
stage_id: str
label: str
sun_teeth: int
planet_teeth: int
bottom_z: float
sun_helix_angle: float
@property
def ring_teeth(self) -> int:
return self.sun_teeth + 2 * self.planet_teeth
@property
def planet_helix_angle(self) -> float:
return -self.sun_helix_angle
@property
def ring_helix_angle(self) -> float:
return self.planet_helix_angle
@property
def gear_height(self) -> float:
return GEAR_HEIGHT
@property
def top_z(self) -> float:
return self.bottom_z + self.gear_height
@property
def mid_z(self) -> float:
return self.bottom_z + self.gear_height / 2.0
@property
def sun_pitch_radius(self) -> float:
return MODULE * self.sun_teeth / 2.0
@property
def planet_pitch_radius(self) -> float:
return MODULE * self.planet_teeth / 2.0
@property
def ring_pitch_radius(self) -> float:
return MODULE * self.ring_teeth / 2.0
@property
def planet_center_radius(self) -> float:
return MODULE * (self.sun_teeth + self.planet_teeth) / 2.0
@property
def fixed_ring_ratio(self) -> float:
return 1.0 + self.ring_teeth / self.sun_teeth
@property
def ring_outer_radius(self) -> float:
tooth_root_allowance = MODULE * (ADDENDUM_FACTOR + CLEARANCE_FACTOR)
return self.ring_pitch_radius + tooth_root_allowance + RING_RIM_THICKNESS
@dataclass(frozen=True)
class BearingSpec:
"""A small radial ball bearing package."""
bore_diameter: float
outer_diameter: float
width: float
ball_diameter: float
ball_count: int
raceway_clearance: float = 0.03
edge_chamfer: float = 0.0
STAGE_1 = StageSpec(
stage_id="stage1",
label="Stage 1",
sun_teeth=12,
planet_teeth=18,
bottom_z=-8.40,
sun_helix_angle=HELIX_ANGLE,
)
STAGE_2 = StageSpec(
stage_id="stage2",
label="Stage 2",
sun_teeth=12,
planet_teeth=12,
bottom_z=1.20,
sun_helix_angle=HELIX_ANGLE,
)
UNIVERSAL_RADIAL_BEARING = BearingSpec(
bore_diameter=3.2,
outer_diameter=6.6,
width=2.0,
ball_diameter=0.55,
ball_count=8,
)
INPUT_SHAFT_BEARING = UNIVERSAL_RADIAL_BEARING
INTERMEDIATE_SHAFT_BEARING = UNIVERSAL_RADIAL_BEARING
OUTPUT_SHAFT_BEARING = UNIVERSAL_RADIAL_BEARING
STAGE1_PLANET_BEARING = UNIVERSAL_RADIAL_BEARING
STAGE2_PLANET_BEARING = UNIVERSAL_RADIAL_BEARING
TOTAL_REDUCTION = STAGE_1.fixed_ring_ratio * STAGE_2.fixed_ring_ratio
@@ -0,0 +1,365 @@
"""Input and output flange parts."""
from __future__ import annotations
import math
import simplecadapi as scad
from common import _apply_tags, make_axis_part_rpart
from dimensions import (
INPUT_FLANGE_BOSS_HEIGHT,
INPUT_FLANGE_BOSS_OUTER_DIAMETER,
INPUT_FLANGE_BOTTOM_Z,
INPUT_FLANGE_HOLE_CIRCLE_DIAMETER,
INPUT_FLANGE_HOLE_COUNT,
INPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
INPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER,
INPUT_FLANGE_HOLE_DIAMETER,
INPUT_FLANGE_INNER_DIAMETER,
INPUT_FLANGE_OUTER_DIAMETER,
INPUT_FLANGE_THICKNESS,
INPUT_FLANGE_TOP_Z,
OUTPUT_FLANGE_BOSS_HEIGHT,
OUTPUT_FLANGE_BOSS_OUTER_DIAMETER,
OUTPUT_FLANGE_BOTTOM_Z,
OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH,
OUTPUT_FLANGE_CENTER_COUNTERBORE_DIAMETER,
OUTPUT_FLANGE_CENTER_FASTENER_CIRCLE_DIAMETER,
OUTPUT_FLANGE_CENTER_FASTENER_COUNT,
OUTPUT_FLANGE_CENTER_FASTENER_DIAMETER,
OUTPUT_FLANGE_HOLE_CIRCLE_DIAMETER,
OUTPUT_FLANGE_HOLE_COUNT,
OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
OUTPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER,
OUTPUT_FLANGE_HOLE_DIAMETER,
OUTPUT_FLANGE_HOLE_OFFSET_DEGREES,
OUTPUT_FLANGE_HOLES_PER_PAD,
OUTPUT_FLANGE_INNER_DIAMETER,
OUTPUT_FLANGE_OUTER_DIAMETER,
OUTPUT_FLANGE_REGISTER_GAP_WIDTH,
OUTPUT_FLANGE_REGISTER_HEIGHT,
OUTPUT_FLANGE_REGISTER_INNER_DIAMETER,
OUTPUT_FLANGE_REGISTER_OUTER_DIAMETER,
OUTPUT_FLANGE_REGISTER_PAD_COUNT,
OUTPUT_FLANGE_THICKNESS,
OUTPUT_FLANGE_TOP_Z,
)
def make_input_flange_rpart(*, material: scad.Material) -> scad.Part:
"""Create the reducer input flange part with six bolt holes."""
flange = _make_n_hole_flange_solid_rsolid(
flange_outer_diameter=INPUT_FLANGE_OUTER_DIAMETER,
flange_inner_diameter=INPUT_FLANGE_INNER_DIAMETER,
flange_thickness=INPUT_FLANGE_THICKNESS,
boss_outer_diameter=INPUT_FLANGE_BOSS_OUTER_DIAMETER,
boss_height=INPUT_FLANGE_BOSS_HEIGHT,
hole_diameter=INPUT_FLANGE_HOLE_DIAMETER,
hole_circle_diameter=INPUT_FLANGE_HOLE_CIRCLE_DIAMETER,
hole_count=INPUT_FLANGE_HOLE_COUNT,
counterbore_diameter=INPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER,
counterbore_depth=INPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
)
flange = scad.translate_shape(
shape=flange,
vector=(0.0, 0.0, INPUT_FLANGE_BOTTOM_Z),
)
flange = _apply_tags(
flange,
tags=("role.input_flange", "group.two_stage_reducer"),
)
print(
f"input_flange: outer_diameter={INPUT_FLANGE_OUTER_DIAMETER:.1f} "
f"top_z={INPUT_FLANGE_TOP_Z:.3f} faces={len(flange.get_faces())}"
)
return make_axis_part_rpart(
part_id="input_flange",
solid=flange,
name="Six-hole input flange",
material=material,
connector_specs=(
{
"connector_id": "axis",
"center_xy": (0.0, 0.0),
"target_z": INPUT_FLANGE_TOP_Z,
"normal_z": 1.0,
},
),
)
def make_output_flange_rpart(*, material: scad.Material) -> scad.Part:
"""Create the reducer output flange part with realistic mounting detail."""
flange = _make_output_flange_solid_rsolid()
flange = scad.translate_shape(
shape=flange,
vector=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z),
)
flange = _apply_tags(
flange,
tags=("role.output_flange", "group.two_stage_reducer"),
)
print(
f"output_flange: outer_diameter={OUTPUT_FLANGE_OUTER_DIAMETER:.1f} "
f"holes={OUTPUT_FLANGE_HOLE_COUNT} top_z={OUTPUT_FLANGE_TOP_Z:.3f} "
f"faces={len(flange.get_faces())}"
)
return make_axis_part_rpart(
part_id="output_flange",
solid=flange,
name="Six-hole output flange",
material=material,
connector_specs=(
{
"connector_id": "axis",
"center_xy": (0.0, 0.0),
"target_z": OUTPUT_FLANGE_TOP_Z,
"normal_z": 1.0,
},
),
)
def _make_output_flange_solid_rsolid() -> scad.Solid:
"""Build the sealed actuator-style output flange.
The earlier example used a small six-hole disk. That was enough to prove
the gear train, but it did not describe how a robot link would actually find
and fasten to the actuator. This output part keeps the simple reducer core
while adding three production-oriented details: a broad rotating face close
to the housing bore, segmented raised register pads for quick angular
location, and separate center fasteners for retaining the output cap.
"""
base = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_OUTER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
boss = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_BOSS_OUTER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_BOSS_HEIGHT + 0.05,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.05),
axis=(0.0, 0.0, 1.0),
)
register_outer = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_OUTER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_REGISTER_HEIGHT + 0.05,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.05),
axis=(0.0, 0.0, 1.0),
)
register_inner = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_REGISTER_HEIGHT + 0.55,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.30),
axis=(0.0, 0.0, 1.0),
)
register = scad.cut_rsolid(register_outer, register_inner, skip_non_intersecting=False)
# The register ring is intentionally shallow and segmented. In real joint
# modules this gives the mating link a positive anti-slip locating feature:
# the link can have matching recesses, so torque is not carried only by screw
# friction while the assembler is trying to align the output face.
flange = scad.union_rsolid([base, boss, register], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_INNER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
]
register_mid_radius = (
OUTPUT_FLANGE_REGISTER_INNER_DIAMETER + OUTPUT_FLANGE_REGISTER_OUTER_DIAMETER
) / 4.0
register_radial_width = (
OUTPUT_FLANGE_REGISTER_OUTER_DIAMETER - OUTPUT_FLANGE_REGISTER_INNER_DIAMETER
) / 2.0
for index in range(OUTPUT_FLANGE_REGISTER_PAD_COUNT):
gap_angle = 60.0 + 360.0 * index / OUTPUT_FLANGE_REGISTER_PAD_COUNT
gap = scad.make_box_rsolid(
width=register_radial_width + 2.2,
height=OUTPUT_FLANGE_REGISTER_GAP_WIDTH,
depth=OUTPUT_FLANGE_REGISTER_HEIGHT + 0.6,
bottom_face_center=(
register_mid_radius,
0.0,
OUTPUT_FLANGE_THICKNESS - 0.25,
),
)
cutters.append(
scad.rotate_shape(
shape=gap,
angle=gap_angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
output_bolt_radius = OUTPUT_FLANGE_HOLE_CIRCLE_DIAMETER / 2.0
output_hole_angles = []
for pad_index in range(OUTPUT_FLANGE_REGISTER_PAD_COUNT):
pad_center_angle = 360.0 * pad_index / OUTPUT_FLANGE_REGISTER_PAD_COUNT
for hole_index in range(OUTPUT_FLANGE_HOLES_PER_PAD):
side = -1.0 if hole_index == 0 else 1.0
output_hole_angles.append(pad_center_angle + side * OUTPUT_FLANGE_HOLE_OFFSET_DEGREES)
for angle_degrees in output_hole_angles:
angle = math.radians(angle_degrees)
x = output_bolt_radius * math.cos(angle)
y = output_bolt_radius * math.sin(angle)
# These holes sit on the raised pads rather than on a flat disk. That is
# the visible design cue from the reference actuator: the pad geometry is
# a locating interface, and the screws clamp through that known land.
cutters.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_HOLE_DIAMETER / 2.0,
height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_REGISTER_HEIGHT + 1.0,
bottom_face_center=(x, y, -0.5),
axis=(0.0, 0.0, 1.0),
)
)
cutters.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER / 2.0,
height=OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH + 0.3,
bottom_face_center=(
x,
y,
OUTPUT_FLANGE_THICKNESS
+ OUTPUT_FLANGE_REGISTER_HEIGHT
- OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
)
)
cap_bolt_radius = OUTPUT_FLANGE_CENTER_FASTENER_CIRCLE_DIAMETER / 2.0
for index in range(OUTPUT_FLANGE_CENTER_FASTENER_COUNT):
angle = 2.0 * math.pi * index / OUTPUT_FLANGE_CENTER_FASTENER_COUNT + math.radians(30.0)
x = cap_bolt_radius * math.cos(angle)
y = cap_bolt_radius * math.sin(angle)
# The center screws read as output-cap retention hardware. Keeping them
# separate from the larger link-mount holes mirrors real actuator stackups:
# service screws retain the internal cap; larger screws attach the robot.
cutters.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_CENTER_FASTENER_DIAMETER / 2.0,
height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT + 1.0,
bottom_face_center=(x, y, -0.5),
axis=(0.0, 0.0, 1.0),
)
)
cutters.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_CENTER_COUNTERBORE_DIAMETER / 2.0,
height=OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH + 0.3,
bottom_face_center=(
x,
y,
OUTPUT_FLANGE_THICKNESS
+ OUTPUT_FLANGE_BOSS_HEIGHT
- OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
)
)
flange = scad.cut_rsolid(flange, cutters, skip_non_intersecting=False)
flange = _apply_tags(
flange,
tags=("role.output_register_pads", "role.link_mount_interface"),
)
print(
f"output_flange_core: od={OUTPUT_FLANGE_OUTER_DIAMETER:.1f} "
f"register_pads={OUTPUT_FLANGE_REGISTER_PAD_COUNT} link_holes={len(output_hole_angles)} "
f"cap_holes={OUTPUT_FLANGE_CENTER_FASTENER_COUNT} faces={len(flange.get_faces())} "
f"volume={flange.get_volume():.3f}"
)
return flange
def _make_n_hole_flange_solid_rsolid(
*,
flange_outer_diameter: float,
flange_inner_diameter: float,
flange_thickness: float,
boss_outer_diameter: float,
boss_height: float,
hole_diameter: float,
hole_circle_diameter: float,
hole_count: int,
counterbore_diameter: float | None = None,
counterbore_depth: float = 0.0,
) -> scad.Solid:
"""Build a flange without edge-pick features so FreeCAD export is stable."""
outer = scad.make_cylinder_rsolid(
radius=flange_outer_diameter / 2.0,
height=flange_thickness,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
boss = scad.make_cylinder_rsolid(
radius=boss_outer_diameter / 2.0,
height=boss_height + 0.05,
bottom_face_center=(0.0, 0.0, flange_thickness - 0.05),
axis=(0.0, 0.0, 1.0),
)
flange = scad.union_rsolid([outer, boss], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=flange_inner_diameter / 2.0,
height=flange_thickness + boss_height + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
]
bolt_circle_radius = hole_circle_diameter / 2.0
for index in range(hole_count):
angle = 2.0 * math.pi * index / hole_count
cutters.append(
scad.make_cylinder_rsolid(
radius=hole_diameter / 2.0,
height=flange_thickness + boss_height + 2.0,
bottom_face_center=(
bolt_circle_radius * math.cos(angle),
bolt_circle_radius * math.sin(angle),
-1.0,
),
axis=(0.0, 0.0, 1.0),
)
)
if counterbore_diameter is not None and counterbore_depth > 0.0:
# Even the input-side service flange gets proper screw head relief.
# Otherwise the front end looks realistic while the motor/input side
# remains a bare demo disk with no way to sit flush against a cover.
cutters.append(
scad.make_cylinder_rsolid(
radius=counterbore_diameter / 2.0,
height=counterbore_depth + 0.3,
bottom_face_center=(
bolt_circle_radius * math.cos(angle),
bolt_circle_radius * math.sin(angle),
flange_thickness - counterbore_depth,
),
axis=(0.0, 0.0, 1.0),
)
)
flange = scad.cut_rsolid(flange, cutters, skip_non_intersecting=False)
print(
f"flange_core: od={flange_outer_diameter:.1f} id={flange_inner_diameter:.1f} "
f"holes={hole_count} hole_d={hole_diameter:.1f} faces={len(flange.get_faces())} "
f"volume={flange.get_volume():.3f}"
)
return flange
@@ -0,0 +1,243 @@
"""Reusable herringbone gear parts for the two-stage reducer."""
from __future__ import annotations
import math
import simplecadapi as scad
from simplecadapi import ql
from common import (
_apply_tags,
add_placement_axis_connector_rpart,
make_axis_part_rpart,
make_z_rotation_rplacement,
)
from dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CLEARANCE_FACTOR,
FIXED_RING_HOUSING_SUPPORT_OVERLAP,
GEAR_HEIGHT,
HOUSING_INNER_RADIUS,
MODULE,
PRESSURE_ANGLE,
RING_RIM_THICKNESS,
BearingSpec,
StageSpec,
)
def make_stage_ring_gear_rpart(
*,
stage: StageSpec,
material: scad.Material,
) -> scad.Part:
"""Create one fixed internal herringbone ring gear part for a stage."""
ring = scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=stage.ring_teeth,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
helix_angle=stage.ring_helix_angle,
gear_height=GEAR_HEIGHT,
rim_thickness=RING_RIM_THICKNESS,
backlash=BACKLASH,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
)
support = scad.make_cylinder_rsolid(
radius=HOUSING_INNER_RADIUS,
height=stage.gear_height,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
support_bore = scad.make_cylinder_rsolid(
radius=stage.ring_outer_radius - FIXED_RING_HOUSING_SUPPORT_OVERLAP,
height=stage.gear_height + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
support = scad.cut_rsolid(
support,
support_bore,
skip_non_intersecting=False,
)
support = scad.apply_tag(shape=support, tag=f"role.{stage.stage_id}.fixed_ring_housing_support")
ring = scad.union_rsolid([ring, support], glue=False)
ring = _apply_tags(
ring,
tags=(f"role.{stage.stage_id}.fixed_ring_gear", "group.two_stage_reducer"),
)
_ground_gear(label=f"{stage.stage_id}_ring", solid=ring)
print(
f"{stage.stage_id}_ring_pitch: teeth={stage.ring_teeth} "
f"pitch_radius={stage.ring_pitch_radius:.3f} outer_radius={stage.ring_outer_radius:.3f} "
f"support_outer_radius={HOUSING_INNER_RADIUS:.3f}"
)
return make_axis_part_rpart(
part_id=f"{stage.stage_id}_ring_gear",
solid=ring,
name=f"{stage.label} fixed herringbone ring gear",
material=material,
connector_specs=(
{
"connector_id": "axis",
"center_xy": (0.0, 0.0),
"target_z": GEAR_HEIGHT,
"normal_z": 1.0,
},
),
)
def make_stage_sun_gear_rpart(
*,
stage: StageSpec,
bore_radius: float,
material: scad.Material,
) -> scad.Part:
"""Create one bored external herringbone sun gear part for a stage."""
sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=stage.sun_teeth,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
helix_angle=stage.sun_helix_angle,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
sun = _cut_bore_rsolid(
label=f"{stage.stage_id}_sun_bore",
solid=sun,
bore_radius=bore_radius,
)
sun = _apply_tags(
sun,
tags=(f"role.{stage.stage_id}.sun_gear", "group.two_stage_reducer"),
)
_ground_gear(label=f"{stage.stage_id}_sun", solid=sun)
print(
f"{stage.stage_id}_sun_pitch: teeth={stage.sun_teeth} "
f"pitch_radius={stage.sun_pitch_radius:.3f} bore_radius={bore_radius:.3f}"
)
return make_axis_part_rpart(
part_id=f"{stage.stage_id}_sun_gear",
solid=sun,
name=f"{stage.label} herringbone sun gear",
material=material,
connector_specs=(
{
"connector_id": "axis",
"center_xy": (0.0, 0.0),
"target_z": GEAR_HEIGHT,
"normal_z": 1.0,
},
),
)
def make_stage_planet_gear_rpart(
*,
stage: StageSpec,
bearing: BearingSpec,
material: scad.Material,
) -> scad.Part:
"""Create a reusable bored herringbone planet gear part for a stage."""
planet = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=stage.planet_teeth,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
helix_angle=stage.planet_helix_angle,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
bore_radius = bearing.outer_diameter / 2.0 + 0.06
planet = _cut_bore_rsolid(
label=f"{stage.stage_id}_planet_bearing_seat",
solid=planet,
bore_radius=bore_radius,
)
planet = _apply_tags(
planet,
tags=(f"role.{stage.stage_id}.planet_gear", "group.two_stage_reducer"),
)
_ground_gear(label=f"{stage.stage_id}_planet", solid=planet)
print(
f"{stage.stage_id}_planet_pitch: teeth={stage.planet_teeth} "
f"pitch_radius={stage.planet_pitch_radius:.3f} bearing_seat_radius={bore_radius:.3f}"
)
part = make_axis_part_rpart(
part_id=f"{stage.stage_id}_planet_gear",
solid=planet,
name=f"{stage.label} reusable herringbone planet gear",
material=material,
connector_specs=(
{
"connector_id": "axis",
"center_xy": (0.0, 0.0),
"target_z": GEAR_HEIGHT,
"normal_z": 1.0,
},
),
)
return add_placement_axis_connector_rpart(
part=part,
connector_id="bearing_axis",
origin=(0.0, 0.0, stage.gear_height / 2.0),
name=f"{stage.label} planet bearing bore axis",
)
def make_planet_component_rplacement(
*,
stage: StageSpec,
planet_index: int,
) -> scad.Placement:
"""Return the placed and phased component placement for one planet gear."""
carrier_angle = 360.0 * planet_index / 3.0
angle_radians = math.radians(carrier_angle)
center = (
stage.planet_center_radius * math.cos(angle_radians),
stage.planet_center_radius * math.sin(angle_radians),
stage.bottom_z,
)
planet_spin = carrier_angle + 180.0 - (180.0 / stage.planet_teeth)
print(
f"{stage.stage_id}_planet_{planet_index + 1}: center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f}) "
f"carrier_angle={carrier_angle:.1f} spin={planet_spin:.1f}"
)
return make_z_rotation_rplacement(origin=center, angle_degrees=planet_spin)
def _cut_bore_rsolid(
*,
label: str,
solid: scad.Solid,
bore_radius: float,
) -> scad.Solid:
cutter = scad.make_cylinder_rsolid(
radius=bore_radius,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
bored = scad.cut_rsolid(solid, cutter, skip_non_intersecting=False)
bored = scad.apply_tag(shape=bored, tag=f"solid.cut.{label}")
print(f"{label}: bore_radius={bore_radius:.3f} volume={bored.get_volume():.3f}")
return bored
def _ground_gear(*, label: str, solid: scad.Solid) -> None:
faces = ql.select(items=solid.get_faces()).all()
edges = ql.select(items=solid.get_edges()).all()
print(
f"gear_{label}: faces={len(faces)} edges={len(edges)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
@@ -0,0 +1,310 @@
"""Reducer housing sleeve and fixed-axis connector datums."""
from __future__ import annotations
import math
import simplecadapi as scad
from common import (
_apply_tags,
add_placement_axis_connector_rpart,
make_annular_cylinder_rsolid,
)
from dimensions import (
HOUSING_BODY_OUTER_RADIUS,
HOUSING_DATUM_INNER_RADIUS,
HOUSING_DATUM_OUTER_RADIUS,
HOUSING_END_FLANGE_OUTER_RADIUS,
HOUSING_FRONT_FLANGE_THICKNESS,
HOUSING_HEIGHT,
HOUSING_INNER_RADIUS,
HOUSING_MOUNT_COUNTERBORE_DEPTH,
HOUSING_MOUNT_COUNTERBORE_DIAMETER,
HOUSING_MOUNT_HOLE_CIRCLE_RADIUS,
HOUSING_MOUNT_HOLE_COUNT,
HOUSING_MOUNT_HOLE_DIAMETER,
HOUSING_MOUNT_HOLE_OFFSET_DEGREES,
HOUSING_MOUNT_HOLES_PER_SECTOR,
HOUSING_MOUNT_PAD_INNER_RADIUS,
HOUSING_MOUNT_PAD_OUTER_RADIUS,
HOUSING_MOUNT_SECTOR_CENTER_OFFSET_DEGREES,
HOUSING_MOUNT_SECTOR_COUNT,
HOUSING_MOUNT_SECTOR_GAP_WIDTH,
HOUSING_OUTER_RADIUS,
HOUSING_BOTTOM_Z,
HOUSING_REAR_FLANGE_THICKNESS,
INPUT_BEARING_Z,
INPUT_FLANGE_TOP_Z,
INPUT_SEAL_BORE_RADIUS,
INPUT_SEAL_RUNNING_CLEARANCE,
INTERMEDIATE_BEARING_Z,
OUTPUT_BEARING_Z,
OUTPUT_FLANGE_TOP_Z,
OUTPUT_SEAL_BORE_RADIUS,
OUTPUT_SEAL_RUNNING_CLEARANCE,
STAGE_1,
STAGE_2,
)
def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
"""Create the through-bolted housing sleeve with internal datum collars."""
sleeve = make_annular_cylinder_rsolid(
outer_radius=HOUSING_BODY_OUTER_RADIUS,
inner_radius=HOUSING_INNER_RADIUS,
height=HOUSING_HEIGHT,
bottom_z=HOUSING_BOTTOM_Z,
tag="role.housing_sleeve",
)
front_flange = _make_end_flange_rsolid(
label="front",
inner_radius=OUTPUT_SEAL_BORE_RADIUS,
thickness=HOUSING_FRONT_FLANGE_THICKNESS,
bottom_z=HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_FRONT_FLANGE_THICKNESS,
)
rear_flange = _make_end_flange_rsolid(
label="rear",
inner_radius=INPUT_SEAL_BORE_RADIUS,
thickness=HOUSING_REAR_FLANGE_THICKNESS,
bottom_z=HOUSING_BOTTOM_Z,
)
mount_pad = _make_mount_sector_pad_rsolid()
collars = []
datum_zs = (
INPUT_FLANGE_TOP_Z,
STAGE_1.top_z,
STAGE_2.top_z,
OUTPUT_FLANGE_TOP_Z,
)
for index, target_z in enumerate(datum_zs):
# The end caps themselves provide the input/output seal lands. Avoid
# adding fully contained datum collars at those end faces; they carry no
# new mechanical information and make downstream translators less robust.
if target_z <= HOUSING_BOTTOM_Z + HOUSING_REAR_FLANGE_THICKNESS:
continue
if target_z >= HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_FRONT_FLANGE_THICKNESS:
continue
collar = make_annular_cylinder_rsolid(
outer_radius=HOUSING_DATUM_OUTER_RADIUS,
inner_radius=HOUSING_DATUM_INNER_RADIUS,
height=0.36,
bottom_z=target_z - 0.36,
tag=f"role.housing_axis_datum_{index + 1}",
)
collars.append(collar)
# The housing now has real through-bolt sector pads. The full-height pad is
# cut after union, so each screw path clears both the visible pad and the
# underlying housing body instead of stopping at a cosmetic front pocket.
housing = scad.union_rsolid([sleeve, front_flange, rear_flange, mount_pad, collars], glue=False)
housing = scad.cut_rsolid(
housing,
[
_make_mount_gap_cutters_rsolids(),
_make_mount_hole_cutters_rsolids(),
],
skip_non_intersecting=False,
)
housing = _apply_tags(
housing,
tags=("role.fixed_housing", "role.case_to_link_interface", "group.two_stage_reducer"),
)
print(
f"housing_through_bolts: sectors={HOUSING_MOUNT_SECTOR_COUNT} holes={HOUSING_MOUNT_HOLE_COUNT} "
f"hole_d={HOUSING_MOUNT_HOLE_DIAMETER:.1f} counterbore_d={HOUSING_MOUNT_COUNTERBORE_DIAMETER:.1f}"
)
print(
f"housing_envelope: diameter={HOUSING_OUTER_RADIUS * 2.0:.1f} "
f"height={HOUSING_HEIGHT:.1f} datum_count={len(datum_zs)} faces={len(housing.get_faces())}"
)
part = scad.make_part_rpart(
part_id="reducer_housing",
body=housing,
name="Compact fixed reducer housing sleeve",
)
part = scad.assign_material_rpart(part=part, material=material)
# The scalloped sector pads deliberately make the output face non-simple.
# Housing axes are design datums, not manufactured face picks, so keep these
# connectors topology-free for stable replay and FreeCAD translation.
for connector_id, z in (
("input_axis", STAGE_1.top_z),
("stage1_axis", STAGE_1.top_z),
("stage2_axis", STAGE_2.top_z),
("output_axis", OUTPUT_FLANGE_TOP_Z),
):
part = add_placement_axis_connector_rpart(
part=part,
connector_id=connector_id,
origin=(0.0, 0.0, z),
name=connector_id.replace("_", " "),
)
for connector_id, z in (
("input_bearing_axis", INPUT_BEARING_Z),
("intermediate_bearing_axis", INTERMEDIATE_BEARING_Z),
("output_bearing_axis", OUTPUT_BEARING_Z),
):
part = add_placement_axis_connector_rpart(
part=part,
connector_id=connector_id,
origin=(0.0, 0.0, z),
name=connector_id.replace("_", " "),
)
print(f"part_reducer_housing: connectors={len(part.connectors)} material=True")
return part
def _make_end_flange_rsolid(
*,
label: str,
inner_radius: float,
thickness: float,
bottom_z: float,
) -> scad.Solid:
"""Build one sealed housing end cap.
The end cap is only the annular plate around the rotating input/output
flange. Housing screws live in the through-bolt columns, not in small
half-depth pockets on this cap.
"""
flange = make_annular_cylinder_rsolid(
outer_radius=HOUSING_END_FLANGE_OUTER_RADIUS,
inner_radius=inner_radius,
height=thickness,
bottom_z=bottom_z,
tag=f"role.housing_{label}_sealed_end_cap",
)
clearance = OUTPUT_SEAL_RUNNING_CLEARANCE if label == "front" else INPUT_SEAL_RUNNING_CLEARANCE
print(
f"{label}_seal_land: bore_radius={inner_radius:.2f} clearance={clearance:.2f}"
)
return flange
def _make_mount_sector_pad_rsolid() -> scad.Solid:
"""Build four graceful full-height sector pads before the global hole cut."""
outer = scad.make_cylinder_rsolid(
radius=HOUSING_MOUNT_PAD_OUTER_RADIUS,
height=HOUSING_HEIGHT,
bottom_face_center=(0.0, 0.0, HOUSING_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
inner = scad.make_cylinder_rsolid(
radius=HOUSING_MOUNT_PAD_INNER_RADIUS,
height=HOUSING_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, HOUSING_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
pad = scad.cut_rsolid(outer, inner, skip_non_intersecting=False)
pad = _apply_tags(
pad,
tags=("role.housing_sector_mount_pads", "role.case_to_link_interface"),
)
print(
f"housing_sector_pads: sectors={HOUSING_MOUNT_SECTOR_COUNT} "
f"holes_per_sector={HOUSING_MOUNT_HOLES_PER_SECTOR} "
f"outer_diameter={HOUSING_MOUNT_PAD_OUTER_RADIUS * 2.0:.1f}"
)
return pad
def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]:
"""Build shallow radial gap cutters that divide the outer band into sectors."""
cutters = []
gap_inner_radius = HOUSING_BODY_OUTER_RADIUS + 0.15
gap_center_radius = (gap_inner_radius + HOUSING_MOUNT_PAD_OUTER_RADIUS + 0.8) / 2.0
gap_radial_depth = HOUSING_MOUNT_PAD_OUTER_RADIUS - gap_inner_radius + 1.0
for index in range(HOUSING_MOUNT_SECTOR_COUNT):
gap_angle = (
HOUSING_MOUNT_SECTOR_CENTER_OFFSET_DEGREES
+ 45.0
+ 360.0 * index / HOUSING_MOUNT_SECTOR_COUNT
)
gap = scad.make_box_rsolid(
width=gap_radial_depth,
height=HOUSING_MOUNT_SECTOR_GAP_WIDTH,
depth=HOUSING_HEIGHT + 2.0,
bottom_face_center=(gap_center_radius, 0.0, HOUSING_BOTTOM_Z - 1.0),
)
cutters.append(
scad.rotate_shape(
shape=gap,
angle=gap_angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
# These cuts shape only the outer mounting band. The inner housing shell is
# intentionally left continuous, so the final part remains one case instead
# of four separate ears connected only by fasteners.
return cutters
def _make_mount_hole_cutters_rsolids() -> list[scad.Solid]:
"""Build one shared cutter set for the boss and housing body holes."""
cutters = []
for sector_index in range(HOUSING_MOUNT_SECTOR_COUNT):
sector_angle_degrees = (
HOUSING_MOUNT_SECTOR_CENTER_OFFSET_DEGREES
+ 360.0 * sector_index / HOUSING_MOUNT_SECTOR_COUNT
)
offsets = (
-HOUSING_MOUNT_HOLE_OFFSET_DEGREES,
0.0,
HOUSING_MOUNT_HOLE_OFFSET_DEGREES,
)
for hole_index in range(HOUSING_MOUNT_HOLES_PER_SECTOR):
angle_degrees = sector_angle_degrees + offsets[hole_index]
angle = math.radians(angle_degrees)
cutters.extend(_make_single_mount_hole_cutters_rsolids(angle=angle))
return cutters
def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]:
"""Build through and counterbore cutters for one housing screw."""
x = HOUSING_MOUNT_HOLE_CIRCLE_RADIUS * math.cos(angle)
y = HOUSING_MOUNT_HOLE_CIRCLE_RADIUS * math.sin(angle)
cutters = []
# The through cutter spans the entire housing. If this stops short, the
# front view still looks like a screw hole, but a real screw would hit the
# rear half of the case exactly as the review screenshot showed.
cutters.append(
scad.make_cylinder_rsolid(
radius=HOUSING_MOUNT_HOLE_DIAMETER / 2.0,
height=HOUSING_HEIGHT + 2.0,
bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
)
# Counterbores are added on both ends so the actuator can be mounted from
# either side during integration or service. This also gives enough head
# diameter to visually read as an M3-class fastener interface.
cutters.append(
scad.make_cylinder_rsolid(
radius=HOUSING_MOUNT_COUNTERBORE_DIAMETER / 2.0,
height=HOUSING_MOUNT_COUNTERBORE_DEPTH + 0.4,
bottom_face_center=(
x,
y,
HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_MOUNT_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
)
)
cutters.append(
scad.make_cylinder_rsolid(
radius=HOUSING_MOUNT_COUNTERBORE_DIAMETER / 2.0,
height=HOUSING_MOUNT_COUNTERBORE_DEPTH + 0.4,
bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 0.2),
axis=(0.0, 0.0, 1.0),
)
)
return cutters
@@ -0,0 +1,86 @@
"""Build, validate, and export the compact two-stage planetary reducer."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import simplecadapi as scad
from assembly import make_two_stage_planetary_reducer_rassembly
from common import _ground_compound
from dimensions import HOUSING_HEIGHT, HOUSING_OUTER_RADIUS, TOTAL_REDUCTION
# Herringbone gear profile graphs are intentionally deep.
sys.setrecursionlimit(30000)
OUT_DIR = Path("examples/out/compact_two_stage_planetary_reducer")
def _build_compact_two_stage_planetary_reducer():
"""Build the reducer and return assembly, preview compound, and JSON exports."""
with scad.GraphSession(graph_id="compact_two_stage_planetary_reducer") as session:
assembly = make_two_stage_planetary_reducer_rassembly()
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
_ground_compound(label="reducer_preview", compound=preview)
session_json = scad.export_session_json(session=session)
model_json = scad.export_model_json(session=session)
return assembly, preview, model_json, session_json
def main() -> None:
"""Generate replayable JSON and STEP output for the reducer example."""
OUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUT_DIR / "compact_two_stage_planetary_reducer.model.json"
session_path = OUT_DIR / "compact_two_stage_planetary_reducer.session.json"
step_path = OUT_DIR / "compact_two_stage_planetary_reducer.step"
fcstd_path = OUT_DIR / "compact_two_stage_planetary_reducer.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json, session_json = _build_compact_two_stage_planetary_reducer()
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
imported = scad.import_model_json(json_str=model_json)
replayed = scad.replay_model_json(json_str=model_json)
payload = json.loads(model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
output_path=str(fcstd_path.resolve()),
document_name="CompactTwoStagePlanetaryReducer",
freecad_cmd=None,
)
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})"
solids = preview.get_solids()
print(f"envelope_diameter={HOUSING_OUTER_RADIUS * 2.0:.1f}")
print(f"envelope_height={HOUSING_HEIGHT:.1f}")
print(f"total_reduction={TOTAL_REDUCTION:.1f}")
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print("constraints=" + ",".join(assembly.constraint_ids()))
print(f"preview_solids={len(solids)}")
print(f"preview_volume={preview.get_volume():.3f}")
print(f"imported_keys={','.join(sorted(imported.keys()))}")
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"session={session_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
"""Material definitions for the compact reducer."""
from __future__ import annotations
import simplecadapi as scad
def make_reducer_materials_rdict() -> dict[str, scad.Material]:
"""Create the small set of reusable material records for the assembly."""
materials = {
"housing": scad.make_material_rmaterial(
material_id="hard_anodized_aluminum",
name="Hard anodized aluminum",
density=2.70e-6,
density_unit="kg/mm^3",
color=(0.26, 0.28, 0.30),
),
"carrier": scad.make_material_rmaterial(
material_id="aluminum_7075",
name="7075 aluminum carrier",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.48, 0.50, 0.52),
),
"gear": scad.make_material_rmaterial(
material_id="case_hardened_steel",
name="Case hardened gear steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.68, 0.70, 0.72),
),
"shaft": scad.make_material_rmaterial(
material_id="tempered_shaft_steel",
name="Tempered shaft steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.55, 0.57, 0.60),
),
}
print("materials: " + ",".join(material.material_id for material in materials.values()))
return materials
@@ -0,0 +1,55 @@
"""Input shaft for the first planetary sun."""
from __future__ import annotations
import simplecadapi as scad
from common import _apply_tags, add_placement_axis_connector_rpart, make_axis_part_rpart
from dimensions import INPUT_BEARING_Z, INPUT_FLANGE_TOP_Z, INPUT_SHAFT_RADIUS, STAGE_1
def make_input_shaft_rpart(*, material: scad.Material) -> scad.Part:
"""Create the input shaft linking the input flange and stage 1 sun."""
height = STAGE_1.top_z - INPUT_FLANGE_TOP_Z
shaft = scad.make_cylinder_rsolid(
radius=INPUT_SHAFT_RADIUS,
height=height,
bottom_face_center=(0.0, 0.0, INPUT_FLANGE_TOP_Z),
axis=(0.0, 0.0, 1.0),
)
shaft = _apply_tags(
shaft,
tags=("role.input_shaft", "group.two_stage_reducer"),
)
print(
f"input_shaft: radius={INPUT_SHAFT_RADIUS:.3f} bottom_z={INPUT_FLANGE_TOP_Z:.3f} "
f"top_z={STAGE_1.top_z:.3f} volume={shaft.get_volume():.3f}"
)
part = make_axis_part_rpart(
part_id="input_shaft",
solid=shaft,
name="Input shaft to first-stage sun",
material=material,
connector_specs=(
{
"connector_id": "flange_axis",
"center_xy": (0.0, 0.0),
"target_z": INPUT_FLANGE_TOP_Z,
"normal_z": -1.0,
"flip": True,
},
{
"connector_id": "sun_axis",
"center_xy": (0.0, 0.0),
"target_z": STAGE_1.top_z,
"normal_z": 1.0,
},
),
)
return add_placement_axis_connector_rpart(
part=part,
connector_id="input_bearing_axis",
origin=(0.0, 0.0, INPUT_BEARING_Z),
name="Input bearing shaft seat axis",
)
@@ -0,0 +1,75 @@
"""Example 17: static current-pose collision verification.
Run from the repository root with:
uv run python examples/17_static_collision_verifier.py
This example checks the current placements of two box components. The verifier
uses internal cached meshes and python-fcl to report mesh contact penetration
deeper than the configured tolerance. It does not solve constraints or detect
complete containment cases.
"""
from __future__ import annotations
import simplecadapi as scad
def _box_part() -> scad.Part:
body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
return scad.make_part_rpart(part_id="unit_box", body=body)
def _assembly_with_offset(offset: tuple[float, float, float]) -> scad.Assembly:
part = _box_part()
assembly = scad.make_assembly_rassembly(assembly_id="static_collision_demo")
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="box_a",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="box_b",
placement=scad.make_placement_rplacement(origin=offset),
)
return assembly
def _print_report(label: str, report: scad.verifier.CollisionReport) -> None:
print(label, f"completed={report.completed}", f"passed={report.passed}")
print(label, f"checked_pairs={report.checked_pair_count}", f"failures={report.failed_pair_count}")
for failure in report.failures:
print(
label,
"failure",
"/".join(failure.component_a),
"/".join(failure.component_b),
f"depth={failure.penetration_depth:.4f}",
f"allowed={failure.allowed_penetration:.4f}",
)
for warning in report.warnings:
print(label, "warning", warning.code, warning.message)
def main() -> None:
config = scad.verifier.CollisionCheckConfig(max_allowed_penetration=0.01)
separated = _assembly_with_offset(offset=(2.0, 0.0, 0.0))
separated_report = scad.verifier.check_collision_rcollisionreport(
assembly=separated,
config=config,
)
_print_report("separated", separated_report)
overlapping = _assembly_with_offset(offset=(0.5, 0.0, 0.0))
overlapping_report = scad.verifier.check_collision_rcollisionreport(
assembly=overlapping,
config=config,
)
_print_report("overlapping", overlapping_report)
if __name__ == "__main__":
main()
@@ -0,0 +1,142 @@
"""Safe reuse boundary for the integrated Example 20 joint actuator."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
import simplecadapi as scad
EXAMPLES_DIR = Path(__file__).resolve().parents[1]
if str(EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(EXAMPLES_DIR))
_assembly = importlib.import_module("20_integrated_bldc_joint_actuator.assembly")
_dimensions = importlib.import_module("20_integrated_bldc_joint_actuator.dimensions")
_materials = importlib.import_module("20_integrated_bldc_joint_actuator.materials")
ACTUATOR_CASE_CLAMP_Z = _dimensions.OUTPUT_CASE_CLAMP_CENTER_Z
ACTUATOR_OUTPUT_FACE_Z = _dimensions.OUTPUT_FLANGE_TOP_Z
ACTUATOR_PACKAGE_RADIUS = _dimensions.PACKAGE_RADIUS
ACTUATOR_PACKAGE_BOTTOM_Z = _dimensions.PACKAGE_STRUCTURAL_BOTTOM_Z
ACTUATOR_PACKAGE_TOP_Z = _dimensions.PACKAGE_TOP_Z
OUTPUT_BOLT_ANGLES_DEGREES = _dimensions.OUTPUT_LINK_BOLT_ANGLES_DEGREES
OUTPUT_BOLT_CIRCLE_RADIUS = _dimensions.OUTPUT_LINK_HOLE_PCD / 2.0
OUTPUT_BOLT_COUNT = _dimensions.OUTPUT_LINK_BOLT_COUNT
OUTPUT_TAP_RADIUS = _dimensions.OUTPUT_LINK_TAP_RADIUS
OUTPUT_REGISTER_HEIGHT = _dimensions.OUTPUT_REGISTER_HEIGHT
OUTPUT_REGISTER_RADIUS = _dimensions.OUTPUT_REGISTER_RADIUS
def make_actuator_materials_rdict() -> dict[str, scad.Material]:
"""Create the externally supplied material set used by Example 20."""
return _materials.make_actuator_materials_rdict()
def make_joint_actuator_rassembly(
*, materials: dict[str, scad.Material]
) -> scad.Assembly:
"""Build the complete actuator as a two-body kinematic subassembly."""
_dimensions.validate_design_dimensions()
component_specs = _assembly.make_integrated_bldc_joint_actuator_components_rtuple(
materials=materials
)
fixed_body = scad.make_assembly_rassembly(
assembly_id="integrated_50mm_bldc_joint_actuator_fixed_body",
name="Rigid actuator housing, motor, electronics, and reducer internals",
)
output_carrier = next(
component for component in component_specs if component[0] == "output_carrier"
)
for component_id, item, source_placement, name in component_specs:
if component_id == "output_carrier":
continue
fixed_body = scad.add_component_rassembly(
assembly=fixed_body,
item=item,
component_id=component_id,
placement=source_placement,
name=name,
)
for connector_id, source_component_id, source_connector_id, name in (
("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"),
("case_mount_axis", "output_bearing_cap", "case_mount_axis", "Fixed actuator case datum"),
("output_support_axis", "reducer_housing", "stage2_carrier_axis", "Output carrier bearing axis"),
("phase_terminal_access", "controller", "phase_access", "Rear phase-terminal service datum"),
("power_can_terminal_access", "controller", "power_can_access", "Rear power/CAN service datum"),
):
fixed_body = scad.forward_connector_rassembly(
assembly=fixed_body,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
)
actuator = scad.make_assembly_rassembly(
assembly_id="leg_joint_actuator",
name="50 mm integrated BLDC actuator with one external output degree of freedom",
)
actuator = scad.add_component_rassembly(
assembly=actuator,
item=fixed_body,
component_id="fixed_body",
placement=scad.identity_placement_rplacement(),
name="Rigid actuator body",
)
actuator = scad.add_component_rassembly(
assembly=actuator,
item=output_carrier[1],
component_id="output_carrier",
placement=output_carrier[2],
name=output_carrier[3],
)
actuator = scad.ground_component_rassembly(
assembly=actuator,
component_id="fixed_body",
)
actuator = scad.add_revolute_constraint_rassembly(
assembly=actuator,
constraint_id="output_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_body",
connector_id="output_support_axis",
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="output_carrier",
connector_id="carrier_axis",
),
name="Actuator output carrier rotation",
)
actuator = scad.solve_assembly_constraints_rassembly(
assembly=actuator,
strict=True,
)
for connector_id, source_component_id, source_connector_id, name in (
("case_clamp_axis", "fixed_body", "case_clamp_axis", "External split-clamp datum"),
("case_mount_axis", "fixed_body", "case_mount_axis", "Fixed actuator case datum"),
("output_link_axis", "output_carrier", "output_link_axis", "Rotating six-hole output flange"),
("phase_terminal_access", "fixed_body", "phase_terminal_access", "Rear phase-terminal service datum"),
("power_can_terminal_access", "fixed_body", "power_can_terminal_access", "Rear power/CAN service datum"),
):
actuator = scad.forward_connector_rassembly(
assembly=actuator,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
)
print(
"leg_joint_actuator: "
f"diameter={ACTUATOR_PACKAGE_RADIUS * 2.0:.1f} "
f"length={ACTUATOR_PACKAGE_TOP_Z - ACTUATOR_PACKAGE_BOTTOM_Z:.1f} "
f"output_pcd={OUTPUT_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"components={len(actuator.component_ids())} revolutes=1 "
f"connectors={','.join(actuator.connector_ids())}"
)
return actuator
@@ -0,0 +1,194 @@
"""Split-clamp actuator mounts for the leg-wheel example."""
from __future__ import annotations
import simplecadapi as scad
from leg_common import make_part_with_connectors_rpart
from leg_dimensions import (
BODY_STANDOFF_THICKNESS,
BODY_STANDOFF_Z,
CASE_CLAMP_INNER_RADIUS,
CASE_CLAMP_OUTER_RADIUS,
CASE_CLAMP_PINCH_AXIS_RADIUS,
CASE_CLAMP_PINCH_HALF_SPAN,
CASE_CLAMP_PINCH_HOLE_RADIUS,
CASE_CLAMP_SLIT_WIDTH,
CASE_CLAMP_WIDTH,
KNEE_CASE_CLAMP_Z,
ROOT_AXIS,
THIGH_CASE_CLAMP_Z,
)
def make_split_case_clamp_rsolid(
*, center: tuple[float, float, float], z_center: float, tag: str
) -> scad.Solid:
"""Create one machinable C-clamp with coaxial pinch-bolt ears."""
z_min = z_center - CASE_CLAMP_WIDTH / 2.0
outer = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_OUTER_RADIUS,
height=CASE_CLAMP_WIDTH,
bottom_face_center=(center[0], center[1], z_min),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_INNER_RADIUS,
height=CASE_CLAMP_WIDTH + 2.0,
bottom_face_center=(center[0], center[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
clamp = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
ear_center_x = center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS - 2.5
ear_center_y = CASE_CLAMP_SLIT_WIDTH / 2.0 + 2.5
ears = [
scad.make_box_rsolid(
width=8.0,
height=5.0,
depth=CASE_CLAMP_WIDTH,
bottom_face_center=(ear_center_x, center[1] + sign * ear_center_y, z_min),
)
for sign in (-1.0, 1.0)
]
clamp = scad.union_rsolid(clamp, ears, glue=False)
slit = scad.make_box_rsolid(
width=15.0,
height=CASE_CLAMP_SLIT_WIDTH,
depth=CASE_CLAMP_WIDTH + 2.0,
bottom_face_center=(center[0] + 31.0, center[1], z_min - 1.0),
)
pinch_hole = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_PINCH_HOLE_RADIUS,
height=CASE_CLAMP_PINCH_HALF_SPAN * 2.0,
bottom_face_center=(
center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
center[1] - CASE_CLAMP_PINCH_HALF_SPAN,
z_center,
),
axis=(0.0, 1.0, 0.0),
)
clamp = scad.cut_rsolid(
clamp,
slit,
pinch_hole,
skip_non_intersecting=False,
)
clamp = scad.apply_tag(shape=clamp, tag=tag)
print(
f"{tag}: bore_d={CASE_CLAMP_INNER_RADIUS * 2.0:.2f} "
f"pinch_hole_d={CASE_CLAMP_PINCH_HOLE_RADIUS * 2.0:.2f} "
f"faces={len(clamp.get_faces())} volume={clamp.get_volume():.3f}"
)
return clamp
def make_body_mount_plate_rpart(*, material: scad.Material) -> scad.Part:
"""Build the two-collar body mount for coaxial tandem root actuators."""
lower = make_split_case_clamp_rsolid(
center=ROOT_AXIS,
z_center=THIGH_CASE_CLAMP_Z,
tag="role.thigh_actuator_split_clamp",
)
upper = make_split_case_clamp_rsolid(
center=ROOT_AXIS,
z_center=KNEE_CASE_CLAMP_Z,
tag="role.knee_drive_split_clamp",
)
lower_z = THIGH_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0
upper_z = KNEE_CASE_CLAMP_Z + CASE_CLAMP_WIDTH / 2.0
post_height = upper_z - lower_z
post_offset_x = 35.0
posts = [
scad.make_cylinder_rsolid(
radius=3.8,
height=post_height,
bottom_face_center=(ROOT_AXIS[0] + sign * post_offset_x, ROOT_AXIS[1], lower_z),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
collar_bridges = [
scad.make_box_rsolid(
width=8.0,
height=7.6,
depth=CASE_CLAMP_WIDTH,
bottom_face_center=(
ROOT_AXIS[0] + sign * 33.0,
ROOT_AXIS[1],
clamp_z - CASE_CLAMP_WIDTH / 2.0,
),
)
for sign in (-1.0, 1.0)
for clamp_z in (THIGH_CASE_CLAMP_Z, KNEE_CASE_CLAMP_Z)
]
lug_z_min = BODY_STANDOFF_Z - BODY_STANDOFF_THICKNESS / 2.0
lugs = [
scad.make_box_rsolid(
width=24.0,
height=18.0,
depth=BODY_STANDOFF_THICKNESS,
bottom_face_center=(ROOT_AXIS[0] + sign * 41.0, ROOT_AXIS[1], lug_z_min),
)
for sign in (-1.0, 1.0)
]
mount = scad.union_rsolid(lower, upper, posts, collar_bridges, lugs, glue=False)
torso_holes = [
scad.make_cylinder_rsolid(
radius=2.3,
height=BODY_STANDOFF_THICKNESS + 2.0,
bottom_face_center=(ROOT_AXIS[0] + sign * 45.0, ROOT_AXIS[1], lug_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
mount = scad.cut_rsolid(mount, torso_holes, skip_non_intersecting=False)
mount = scad.apply_tag(shape=mount, tag="role.body_mount_plate")
print(
"body_mount_plate: root_actuators=2 tandem=true "
f"clamp_z=({THIGH_CASE_CLAMP_Z:.1f},{KNEE_CASE_CLAMP_Z:.1f}) "
f"torso_holes=2 faces={len(mount.get_faces())} volume={mount.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="body_mount_plate",
body=mount,
name="Coaxial tandem root actuator split-clamp and body lug bracket",
material=material,
connectors=(
("case_axis", (ROOT_AXIS[0], ROOT_AXIS[1], THIGH_CASE_CLAMP_Z), "z", "Thigh actuator clamp datum"),
(
"knee_drive_case_axis",
(ROOT_AXIS[0], ROOT_AXIS[1], KNEE_CASE_CLAMP_Z),
"z",
"Knee-drive actuator clamp datum opposite the crank",
),
(
"thigh_clamp_bolt_seat",
(
ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
THIGH_CASE_CLAMP_Z,
),
"y",
"Thigh collar M4 bolt head seat",
),
(
"knee_clamp_bolt_seat",
(
ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
KNEE_CASE_CLAMP_Z,
),
"y",
"Knee-drive collar M4 bolt head seat",
),
(
"body_frame_axis",
(ROOT_AXIS[0], ROOT_AXIS[1], BODY_STANDOFF_Z),
"z",
"Body frame datum",
),
),
)
@@ -0,0 +1,151 @@
"""Static external-envelope collision probe for the rebuilt Example 18."""
from __future__ import annotations
import contextlib
import io
import sys
import time
import simplecadapi as scad
from actuator import make_actuator_materials_rdict
from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly
from leg_materials import make_leg_materials_rdict
ACTUATOR_IDS = (
"thigh_actuator",
"knee_drive_actuator",
"wheel_hub_actuator",
)
EXTERNAL_ACTUATOR_LEAVES = (
("reducer_housing",),
("motor_shell",),
("rear_electronics_cover",),
("output_bearing_cap",),
("output_carrier",),
("controller", "three_phase_terminal"),
("controller", "power_can_terminal"),
)
TOP_LEVEL_EXTERNALS = (
"body_mount_plate",
"upper_link_plate",
"proximal_output_crank",
"knee_pushrod",
"shank_link",
"wheel_hub",
"wheel_tire",
"knee_bushing",
"knee_axle",
"thigh_clamp_bolt",
"knee_drive_clamp_bolt",
"wheel_clamp_bolt",
"proximal_linkage_pin",
"distal_linkage_pin",
)
def _leg_level_component_paths() -> tuple[tuple[str, ...], ...]:
paths: list[tuple[str, ...]] = []
for actuator_id in ACTUATOR_IDS:
for leaf in EXTERNAL_ACTUATOR_LEAVES:
if leaf == ("output_carrier",):
paths.append((actuator_id, *leaf))
else:
paths.append((actuator_id, "fixed_body", *leaf))
paths.extend((component_id,) for component_id in TOP_LEVEL_EXTERNALS)
for interface in ("thigh", "knee_drive", "wheel"):
paths.extend((f"{interface}_output_screw_{index}",) for index in range(1, 7))
return tuple(paths)
def _intentional_mating_pairs() -> tuple[scad.verifier.ComponentPair, ...]:
pairs = [
scad.verifier.ComponentPair("wheel_hub", "wheel_tire"),
scad.verifier.ComponentPair("body_mount_plate", "thigh_clamp_bolt"),
scad.verifier.ComponentPair("body_mount_plate", "knee_drive_clamp_bolt"),
scad.verifier.ComponentPair("shank_link", "wheel_clamp_bolt"),
]
for actuator_id in ACTUATOR_IDS:
pairs.append(
scad.verifier.ComponentPair(
(actuator_id, "fixed_body", "reducer_housing"),
(actuator_id, "fixed_body", "output_bearing_cap"),
)
)
for interface, actuator_id, driven_component_id in (
("thigh", "thigh_actuator", "upper_link_plate"),
("knee_drive", "knee_drive_actuator", "proximal_output_crank"),
("wheel", "wheel_hub_actuator", "wheel_hub"),
):
pairs.append(
scad.verifier.ComponentPair(
(actuator_id, "output_carrier"),
driven_component_id,
)
)
for index in range(1, 7):
pairs.append(
scad.verifier.ComponentPair(
f"{interface}_output_screw_{index}",
(actuator_id, "output_carrier"),
)
)
return tuple(pairs)
def main() -> None:
sys.setrecursionlimit(40000)
build_log = io.StringIO()
start = time.perf_counter()
with contextlib.redirect_stdout(build_log):
assembly = make_leg_wheel_robot_dog_leg_rassembly(
actuator_materials=make_actuator_materials_rdict(),
leg_materials=make_leg_materials_rdict(),
)
build_seconds = time.perf_counter() - start
config = scad.verifier.CollisionCheckConfig(
max_allowed_penetration=0.08,
max_contacts_per_pair=32,
scope=scad.verifier.CollisionScope(
component_paths=_leg_level_component_paths(),
exclude_pairs=_intentional_mating_pairs(),
),
)
start = time.perf_counter()
report = scad.verifier.check_collision_rcollisionreport(
assembly=assembly,
config=config,
)
check_seconds = time.perf_counter() - start
print(f"assembly {assembly.assembly_id}")
print(f"build_log_lines {len(build_log.getvalue().splitlines())}")
print(f"build_seconds {build_seconds:.3f}")
print(f"check_seconds {check_seconds:.3f}")
print(f"completed {report.completed}")
print(f"passed {report.passed}")
print(f"checked_pair_count {report.checked_pair_count}")
print(f"failed_pair_count {report.failed_pair_count}")
print(f"warning_count {len(report.warnings)}")
for warning in report.warnings:
path = "/".join(warning.component_path or ())
print(f"warning {path} {warning.code} {warning.message}")
for failure in sorted(
report.failures,
key=lambda item: item.penetration_depth,
reverse=True,
)[:25]:
print(
"failure",
"/".join(failure.component_a),
"/".join(failure.component_b),
f"depth={failure.penetration_depth:.3f}",
f"allowed={failure.allowed_penetration:.3f}",
)
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""Modeled bolts, threaded fasteners, and joint bushings for Example 18."""
from __future__ import annotations
import simplecadapi as scad
from leg_common import make_part_with_connectors_rpart
from leg_dimensions import KNEE_AXIS
def make_socket_head_screw_rpart(
*,
part_id: str,
shank_radius: float,
shank_length: float,
head_radius: float,
head_height: float,
material: scad.Material,
) -> scad.Part:
"""Create a socket-head screw with its seat plane at local Z=0."""
shank = scad.make_cylinder_rsolid(
radius=shank_radius,
height=shank_length,
bottom_face_center=(0.0, 0.0, -head_height - shank_length),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=head_radius,
height=head_height,
bottom_face_center=(0.0, 0.0, -head_height),
axis=(0.0, 0.0, 1.0),
)
screw = scad.union_rsolid(shank, head, glue=False)
screw = scad.apply_tag(shape=screw, tag="role.socket_head_screw")
return make_part_with_connectors_rpart(
part_id=part_id,
body=screw,
name=f"Socket-head screw {shank_radius * 2.0:.1f} x {shank_length:.1f} mm",
material=material,
connectors=(("head_top_axis", (0.0, 0.0, 0.0), "z", "Flush screw head top plane"),),
)
def make_clamp_bolt_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create an M4 bolt plus flange-nut stack for a split collar."""
shank_length = 12.2
shank = scad.make_cylinder_rsolid(
radius=2.0,
height=shank_length,
bottom_face_center=(0.0, 0.0, -shank_length),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=3.6,
height=3.2,
bottom_face_center=(0.0, 0.0, -0.05),
axis=(0.0, 0.0, 1.0),
)
nut = scad.make_cylinder_rsolid(
radius=3.8,
height=3.2,
bottom_face_center=(0.0, 0.0, -shank_length - 3.15),
axis=(0.0, 0.0, 1.0),
)
stack = scad.union_rsolid(shank, head, nut, glue=False)
stack = scad.apply_tag(shape=stack, tag="role.clamp_bolt_and_nut")
return make_part_with_connectors_rpart(
part_id="m4_split_clamp_bolt_stack",
body=stack,
name="M4 split-clamp socket bolt and flange nut",
material=material,
connectors=(("seat_axis", (0.0, 0.0, 0.0), "z", "Clamp bolt head seat"),),
)
def make_knee_bushing_rpart(*, material: scad.Material) -> scad.Part:
"""Create a continuous bronze knee sleeve and axial spacer."""
outer = scad.make_cylinder_rsolid(
radius=5.95,
height=13.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=3.2,
height=15.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -1.0),
axis=(0.0, 0.0, 1.0),
)
sleeve = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
sleeve = scad.apply_tag(shape=sleeve, tag="role.knee_bearing_bushing")
return make_part_with_connectors_rpart(
part_id="knee_bronze_bushing",
body=sleeve,
name="12 mm OD bronze knee bushing and spacer",
material=material,
connectors=(
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee revolute axis"),
("bolt_head_top_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 16.0), "z", "Shoulder bolt head top plane"),
),
)
def make_knee_shoulder_bolt_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create the knee shoulder axle, socket head, and retained nut."""
shaft = scad.make_cylinder_rsolid(
radius=3.0,
height=13.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=5.5,
height=3.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 13.0),
axis=(0.0, 0.0, 1.0),
)
nut = scad.make_cylinder_rsolid(
radius=5.5,
height=4.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -4.0),
axis=(0.0, 0.0, 1.0),
)
axle = scad.union_rsolid(shaft, head, nut, glue=False)
axle = scad.apply_tag(shape=axle, tag="role.knee_shoulder_axle")
return make_part_with_connectors_rpart(
part_id="knee_shoulder_bolt_stack",
body=axle,
name="M6 knee shoulder axle with socket head and retained nut",
material=material,
connectors=(("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee axle axis"),),
)
def make_linkage_pin_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create a retained M4 shoulder pin spanning crank, gap, and pushrod."""
span = 9.3
shaft = scad.make_cylinder_rsolid(
radius=2.0,
height=span,
bottom_face_center=(0.0, 0.0, -span / 2.0),
axis=(0.0, 0.0, 1.0),
)
retainers = [
scad.make_cylinder_rsolid(
radius=3.5,
height=1.2,
bottom_face_center=(0.0, 0.0, sign * span / 2.0 - (1.2 if sign < 0.0 else 0.0)),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
pin = scad.union_rsolid(shaft, retainers, glue=False)
pin = scad.apply_tag(shape=pin, tag="role.retained_linkage_pin")
return make_part_with_connectors_rpart(
part_id="m4_linkage_shoulder_pin_stack",
body=pin,
name="M4 retained linkage shoulder pin",
material=material,
connectors=(("pin_axis", (0.0, 0.0, 0.0), "z", "Linkage pin axis"),),
)
@@ -0,0 +1,254 @@
"""Top-level bolt-aligned leg-wheel robot dog leg assembly."""
from __future__ import annotations
import simplecadapi as scad
from actuator import make_joint_actuator_rassembly
from brackets import make_body_mount_plate_rpart
from hardware import (
make_clamp_bolt_stack_rpart,
make_knee_bushing_rpart,
make_knee_shoulder_bolt_stack_rpart,
make_linkage_pin_stack_rpart,
make_socket_head_screw_rpart,
)
from leg_common import connector_ref, make_actuator_target_rplacement
from leg_dimensions import (
ACTUATOR_OUTPUT_CONNECTOR_Z,
DISTAL_PUSHROD_PIN,
KNEE_DRIVE_AXIS,
PROXIMAL_PUSHROD_PIN,
OUTPUT_FLANGE_SCREW_SHANK_RADIUS,
ROD_PIN_AXIS_Z,
ROOT_AXIS,
WHEEL_AXIS,
)
from links import (
make_proximal_crank_rpart,
make_pushrod_rpart,
make_shank_link_rpart,
make_upper_link_plate_rpart,
make_wheel_hub_rpart,
make_wheel_tire_rpart,
)
def make_leg_wheel_robot_dog_leg_rassembly(
*,
actuator_materials: dict[str, scad.Material],
leg_materials: dict[str, scad.Material],
) -> scad.Assembly:
"""Build the posed planar leg-wheel assembly with explicit bolt interfaces."""
actuator = make_joint_actuator_rassembly(materials=actuator_materials)
body_mount = make_body_mount_plate_rpart(material=leg_materials["bracket"])
upper_link = make_upper_link_plate_rpart(material=leg_materials["link"])
proximal_crank = make_proximal_crank_rpart(material=leg_materials["linkage"])
pushrod = make_pushrod_rpart(material=leg_materials["linkage"])
shank_link = make_shank_link_rpart(material=leg_materials["link"])
wheel_hub = make_wheel_hub_rpart(material=leg_materials["wheel_hub"])
wheel_tire = make_wheel_tire_rpart(material=leg_materials["tire"])
output_screw = make_socket_head_screw_rpart(
part_id="m3x5_output_socket_head_screw",
shank_radius=OUTPUT_FLANGE_SCREW_SHANK_RADIUS,
shank_length=5.0,
head_radius=2.85,
head_height=3.0,
material=leg_materials["fastener"],
)
clamp_bolt = make_clamp_bolt_stack_rpart(material=leg_materials["fastener"])
linkage_pin = make_linkage_pin_stack_rpart(material=leg_materials["fastener"])
knee_bushing = make_knee_bushing_rpart(material=leg_materials["bushing"])
knee_axle = make_knee_shoulder_bolt_stack_rpart(material=leg_materials["fastener"])
leg = scad.make_assembly_rassembly(
assembly_id="leg_wheel_robot_dog_leg",
name="Planar leg-wheel module with bolt-aligned actuator, knee, and wheel interfaces",
)
for component_id, target, axis, name in (
("thigh_actuator", ROOT_AXIS, "z", "Body-fixed thigh reducer actuator"),
("knee_drive_actuator", KNEE_DRIVE_AXIS, "z", "Body-fixed knee-drive actuator opposite the crank"),
("wheel_hub_actuator", WHEEL_AXIS, "z", "Distal wheel hub reducer actuator"),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=actuator,
component_id=component_id,
placement=make_actuator_target_rplacement(
output_axis_origin=target,
output_axis_local_z=ACTUATOR_OUTPUT_CONNECTOR_Z,
axis=axis,
),
name=name,
)
for component_id, item, name in (
("body_mount_plate", body_mount, "Body-fixed hip stack bracket for thigh and knee-drive cases"),
("upper_link_plate", upper_link, "Output-bolted upper link plate"),
("proximal_output_crank", proximal_crank, "Output-bolted knee-drive crank"),
("knee_pushrod", pushrod, "Pinned pushrod between crank and shank"),
("shank_link", shank_link, "Lower shank with integral pushrod ear and wheel hub case mount"),
("wheel_hub", wheel_hub, "Rigid wheel hub bolted to actuator output"),
("wheel_tire", wheel_tire, "Replaceable rubber tire fitted to rigid wheel hub"),
("knee_bushing", knee_bushing, "Bronze knee pivot sleeve"),
("knee_axle", knee_axle, "Retained knee shoulder axle"),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=item,
component_id=component_id,
placement=scad.identity_placement_rplacement(),
name=name,
)
for component_id, item, placement, name in (
(
"thigh_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 thigh actuator split-clamp bolt",
),
(
"knee_drive_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 knee-drive actuator split-clamp bolt",
),
(
"wheel_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 wheel actuator split-clamp bolt",
),
(
"proximal_linkage_pin",
linkage_pin,
scad.make_placement_rplacement(
origin=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z)
),
"Retained proximal linkage shoulder pin",
),
(
"distal_linkage_pin",
linkage_pin,
scad.make_placement_rplacement(
origin=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z)
),
"Retained distal linkage shoulder pin",
),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
for interface, plate_component in (
("thigh", "upper_link_plate"),
("knee_drive", "proximal_output_crank"),
("wheel", "wheel_hub"),
):
for index in range(1, 7):
leg = scad.add_component_rassembly(
assembly=leg,
item=output_screw,
component_id=f"{interface}_output_screw_{index}",
placement=scad.identity_placement_rplacement(),
name=f"{interface.replace('_', ' ')} output M3 screw {index}",
)
leg = _add_leg_constraints_rassembly(assembly=leg)
leg = scad.solve_assembly_constraints_rassembly(assembly=leg, strict=True)
_ground_constraint_report(assembly=leg)
print(
"leg_components: actuators=3 structural=7 hardware=25 components="
f"{len(leg.component_ids())} constraints={len(leg.constraint_ids())}"
)
return leg
def _add_leg_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="body_mount_plate")
fixed_pairs = (
("hip_stack_to_thigh_case", "body_mount_plate", "case_axis", "thigh_actuator", "case_clamp_axis"),
("hip_stack_to_knee_drive_case", "body_mount_plate", "knee_drive_case_axis", "knee_drive_actuator", "case_clamp_axis"),
("shank_clamped_to_wheel_case", "shank_link", "wheel_case_axis", "wheel_hub_actuator", "case_clamp_axis"),
("thigh_output_bolted_to_link", "thigh_actuator", "output_link_axis", "upper_link_plate", "output_axis"),
("knee_output_bolted_to_crank", "knee_drive_actuator", "output_link_axis", "proximal_output_crank", "output_axis"),
("wheel_output_bolted_to_hub", "wheel_hub_actuator", "output_link_axis", "wheel_hub", "wheel_axis"),
("wheel_tire_bonded_to_hub", "wheel_hub", "tire_axis", "wheel_tire", "hub_axis"),
("knee_bushing_pressed_in_upper_link", "upper_link_plate", "knee_axis", "knee_bushing", "knee_axis"),
("knee_axle_locked_to_bushing", "knee_bushing", "knee_axis", "knee_axle", "knee_axis"),
("thigh_clamp_bolt_seated", "body_mount_plate", "thigh_clamp_bolt_seat", "thigh_clamp_bolt", "seat_axis"),
("knee_clamp_bolt_seated", "body_mount_plate", "knee_clamp_bolt_seat", "knee_drive_clamp_bolt", "seat_axis"),
("wheel_clamp_bolt_seated", "shank_link", "wheel_clamp_bolt_seat", "wheel_clamp_bolt", "seat_axis"),
("proximal_pin_locked_to_crank", "proximal_output_crank", "rod_pin", "proximal_linkage_pin", "pin_axis"),
("distal_pin_locked_to_shank", "shank_link", "rod_pin", "distal_linkage_pin", "pin_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in fixed_pairs:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
name=constraint_id.replace("_", " "),
)
revolutes = (
("proximal_pushrod_on_shoulder_pin", "proximal_linkage_pin", "pin_axis", "knee_pushrod", "proximal_pin", None),
("distal_pushrod_on_shoulder_pin", "distal_linkage_pin", "pin_axis", "knee_pushrod", "distal_pin", None),
("shank_rotates_on_knee_bushing", "knee_bushing", "knee_axis", "shank_link", "knee_axis", None),
)
for constraint_id, a_component, a_connector, b_component, b_connector, drive_angle in revolutes:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=drive_angle,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
for interface, plate_component in (
("thigh", "upper_link_plate"),
("knee_drive", "proximal_output_crank"),
("wheel", "wheel_hub"),
):
for index in range(1, 7):
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{interface}_output_screw_{index}_seated",
connector_a=connector_ref(
component_id=plate_component,
connector_id=f"output_bolt_{index}_head_top",
),
connector_b=connector_ref(
component_id=f"{interface}_output_screw_{index}",
connector_id="head_top_axis",
),
name=f"{interface.replace('_', ' ')} output screw {index} coaxial",
)
print(
f"leg_constraints_added: fixed={len(fixed_pairs) + 18} "
f"revolute={len(revolutes)} actuator_output_mounts=fixed bolt_aligned=21"
)
return assembly
def _ground_constraint_report(*, assembly: scad.Assembly) -> None:
report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly)
print(
f"leg_constraints: solved={report.solved} grounded={len(report.grounded_component_ids)} "
f"solved_components={len(report.solved_component_ids)} unsolved={len(report.unsolved_component_ids)}"
)
for residual in report.residuals:
print(
f"leg_constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}"
)
@@ -0,0 +1,343 @@
"""Shared geometry helpers for Example 18."""
from __future__ import annotations
import math
from collections.abc import Iterable
import simplecadapi as scad
from simplecadapi import ql
Point3 = tuple[float, float, float]
def bolt_circle_points(
*,
center: Point3,
radius: float,
angles_degrees: Iterable[float],
) -> tuple[Point3, ...]:
"""Return XY bolt-center points on a named bolt-circle datum."""
points = []
for angle_degrees in angles_degrees:
angle = math.radians(angle_degrees)
points.append(
(
center[0] + radius * math.cos(angle),
center[1] + radius * math.sin(angle),
center[2],
)
)
return tuple(points)
def make_bolt_circle_cutters_rsolidlist(
*,
center: Point3,
bolt_circle_radius: float,
angles_degrees: Iterable[float],
hole_radius: float,
z_min: float,
height: float,
counterbore_radius: float | None = None,
counterbore_depth: float = 0.0,
counterbore_from_top: bool = True,
counterbore_face_z: float | None = None,
) -> list[scad.Solid]:
"""Build through-hole and optional counterbore cutters for a bolt circle."""
cutters: list[scad.Solid] = []
for point in bolt_circle_points(
center=center,
radius=bolt_circle_radius,
angles_degrees=angles_degrees,
):
cutters.append(
scad.make_cylinder_rsolid(
radius=hole_radius,
height=height,
bottom_face_center=(point[0], point[1], z_min),
axis=(0.0, 0.0, 1.0),
)
)
if counterbore_radius is not None and counterbore_depth > 0.0:
face_z = counterbore_face_z
if face_z is None:
face_z = z_min + height if counterbore_from_top else z_min
counterbore_z = face_z - counterbore_depth if counterbore_from_top else face_z - 0.2
cutters.append(
scad.make_cylinder_rsolid(
radius=counterbore_radius,
height=counterbore_depth + 0.2,
bottom_face_center=(point[0], point[1], counterbore_z),
axis=(0.0, 0.0, 1.0),
)
)
return cutters
def make_rounded_slot_cutter_rsolid(
*,
center: Point3,
length: float,
width: float,
height: float,
angle_degrees: float,
tag: str,
) -> scad.Solid:
"""Build a capsule-shaped cutter for a lightening pocket."""
if length <= width:
raise ValueError("rounded slot length must exceed width")
radius = width / 2.0
straight = length - width
z_min = center[2] - height / 2.0
bridge = scad.make_box_rsolid(
width=straight,
height=width,
depth=height,
bottom_face_center=(0.0, 0.0, z_min),
)
left = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=(-straight / 2.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
right = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=(straight / 2.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
cutter = scad.union_rsolid([bridge, left, right], glue=False)
cutter = scad.rotate_shape(
shape=cutter,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, center[2]),
)
cutter = scad.translate_shape(shape=cutter, vector=(center[0], center[1], 0.0))
return scad.apply_tag(shape=cutter, tag=tag)
def make_axis_placement_rplacement(
*,
origin: Point3,
axis: str = "z",
) -> scad.Placement:
"""Create a placement whose local Z axis is the requested world axis."""
if axis == "z":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 1.0, 0.0),
)
if axis == "y":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 0.0, -1.0),
)
if axis == "-z":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, -1.0, 0.0),
)
if axis == "x":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(0.0, 1.0, 0.0),
y_axis=(0.0, 0.0, 1.0),
)
raise ValueError(f"unsupported axis {axis!r}")
def make_actuator_target_rplacement(
*,
output_axis_origin: Point3,
output_axis_local_z: float,
axis: str,
) -> scad.Placement:
"""Place an actuator so its forwarded output/case axis lands on a world point."""
if axis == "z":
origin = (
output_axis_origin[0],
output_axis_origin[1],
output_axis_origin[2] - output_axis_local_z,
)
elif axis == "y":
origin = (
output_axis_origin[0],
output_axis_origin[1] - output_axis_local_z,
output_axis_origin[2],
)
elif axis == "-z":
origin = (
output_axis_origin[0],
output_axis_origin[1],
output_axis_origin[2] + output_axis_local_z,
)
else:
raise ValueError(f"unsupported axis {axis!r}")
return make_axis_placement_rplacement(origin=origin, axis=axis)
def add_datum_connector_rpart(
*,
part: scad.Part,
connector_id: str,
origin: Point3,
axis: str = "z",
name: str | None = None,
) -> scad.Part:
"""Attach a topology-free connector datum to a part."""
connector = scad.make_placement_connector_rconnector(
connector_id=connector_id,
placement=make_axis_placement_rplacement(origin=origin, axis=axis),
name=name,
)
return scad.add_connector_rpart(part=part, connector=connector)
def make_part_with_connectors_rpart(
*,
part_id: str,
body: scad.Solid,
name: str,
material: scad.Material,
connectors: Iterable[tuple[str, Point3, str, str | None]],
) -> scad.Part:
"""Wrap a solid as a materialized Part and add placement connectors."""
part = scad.make_part_rpart(part_id=part_id, body=body, name=name)
part = scad.assign_material_rpart(part=part, material=material)
for connector_id, origin, axis, connector_name in connectors:
part = add_datum_connector_rpart(
part=part,
connector_id=connector_id,
origin=origin,
axis=axis,
name=connector_name,
)
print(f"part_{part_id}: connectors={len(part.connectors)} volume={body.get_volume():.3f}")
return part
def make_rounded_bar_rsolid(
*,
start: Point3,
end: Point3,
width: float,
thickness: float,
end_hole_radius: float,
lightening_hole_radius: float | None = None,
lightening_count: int = 0,
tag: str,
) -> scad.Solid:
"""Build a planar rounded-end plate between two XY points at constant Z."""
dx = end[0] - start[0]
dy = end[1] - start[1]
length = math.hypot(dx, dy)
if length <= width:
raise ValueError("rounded bar length must exceed width")
z_center = start[2]
z_min = z_center - thickness / 2.0
radius = width / 2.0
bridge = scad.make_box_rsolid(
width=length,
height=width,
depth=thickness,
bottom_face_center=(length / 2.0, 0.0, z_min),
)
left = scad.make_cylinder_rsolid(
radius=radius,
height=thickness,
bottom_face_center=(0.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
right = scad.make_cylinder_rsolid(
radius=radius,
height=thickness,
bottom_face_center=(length, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
body = scad.union_rsolid([bridge, left, right], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=end_hole_radius,
height=thickness + 2.0,
bottom_face_center=(0.0, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=end_hole_radius,
height=thickness + 2.0,
bottom_face_center=(length, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
if lightening_hole_radius is not None and lightening_count > 0:
for index in range(lightening_count):
fraction = (index + 1.0) / (lightening_count + 1.0)
cutters.append(
scad.make_cylinder_rsolid(
radius=lightening_hole_radius,
height=thickness + 2.0,
bottom_face_center=(length * fraction, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
)
body = scad.cut_rsolid(body, cutters, skip_non_intersecting=False)
body = scad.rotate_shape(
shape=body,
angle=math.degrees(math.atan2(dy, dx)),
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, z_center),
)
body = scad.translate_shape(shape=body, vector=(start[0], start[1], 0.0))
body = scad.apply_tag(shape=body, tag=tag)
_ground_solid(label=tag, solid=body)
return body
def _ground_solid(*, label: str, solid: scad.Solid) -> None:
faces = ql.select(items=solid.get_faces()).all()
print(f"{label}: faces={len(faces)} volume={solid.get_volume():.3f}")
def _axis_vector(*, axis: str) -> Point3:
if axis == "z":
return (0.0, 0.0, 1.0)
if axis == "y":
return (0.0, 1.0, 0.0)
if axis == "x":
return (1.0, 0.0, 0.0)
if axis == "-z":
return (0.0, 0.0, -1.0)
raise ValueError(f"unsupported axis {axis!r}")
def ground_compound(*, label: str, compound: scad.Compound) -> None:
"""Print compact grounding facts for an assembly preview."""
solids = ql.select(items=compound.get_solids()).all()
face_count = sum(len(ql.select(items=solid.get_faces()).all()) for solid in solids)
print(
f"{label}: solids={len(solids)} faces={face_count} "
f"volume={compound.get_volume():.3f}"
)
def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
@@ -0,0 +1,173 @@
"""Design constants for the bolt-aligned planar leg-wheel example."""
from __future__ import annotations
import math
from actuator import (
ACTUATOR_CASE_CLAMP_Z,
ACTUATOR_OUTPUT_FACE_Z,
ACTUATOR_PACKAGE_RADIUS,
OUTPUT_BOLT_ANGLES_DEGREES,
OUTPUT_BOLT_CIRCLE_RADIUS,
OUTPUT_BOLT_COUNT,
OUTPUT_TAP_RADIUS,
OUTPUT_REGISTER_HEIGHT,
OUTPUT_REGISTER_RADIUS,
)
# This example intentionally drops the hip-abduction package for now. The model
# is one planar leg module like the reference sketch: one body-fixed thigh
# actuator drives the upper link, a second body-fixed coaxial knee-drive actuator
# drives a crank/pushrod, and a third hub actuator drives the wheel.
ROOT_AXIS = (0.0, 0.0, 0.0)
KNEE_AXIS = (42.0, -126.0, 0.0)
WHEEL_AXIS = (-78.0, -220.0, 0.0)
KNEE_PIVOT_Z = 6.5
# The knee-drive actuator is mirrored about its unchanged split-clamp plane, so
# its output flange and red crank sit on the side opposite the actuator body.
KNEE_DRIVE_OUTPUT_Z = 85.6
ROD_PIN_AXIS_Z = 85.95
THIGH_VECTOR_X = KNEE_AXIS[0] - ROOT_AXIS[0]
THIGH_VECTOR_Y = KNEE_AXIS[1] - ROOT_AXIS[1]
THIGH_LENGTH = math.hypot(THIGH_VECTOR_X, THIGH_VECTOR_Y)
THIGH_UNIT_X = THIGH_VECTOR_X / THIGH_LENGTH
THIGH_UNIT_Y = THIGH_VECTOR_Y / THIGH_LENGTH
THIGH_NORMAL_X = -THIGH_UNIT_Y
THIGH_NORMAL_Y = THIGH_UNIT_X
SHANK_VECTOR_X = WHEEL_AXIS[0] - KNEE_AXIS[0]
SHANK_VECTOR_Y = WHEEL_AXIS[1] - KNEE_AXIS[1]
SHANK_LENGTH = math.hypot(SHANK_VECTOR_X, SHANK_VECTOR_Y)
SHANK_UNIT_X = SHANK_VECTOR_X / SHANK_LENGTH
SHANK_UNIT_Y = SHANK_VECTOR_Y / SHANK_LENGTH
SHANK_NORMAL_X = -SHANK_UNIT_Y
SHANK_NORMAL_Y = SHANK_UNIT_X
REMOTE_CRANK_LENGTH = 46.0
DISTAL_CRANK_LENGTH = REMOTE_CRANK_LENGTH
KNEE_DRIVE_AXIS = (
ROOT_AXIS[0],
ROOT_AXIS[1],
KNEE_DRIVE_OUTPUT_Z,
)
PROXIMAL_PUSHROD_PIN = (
ROOT_AXIS[0] + THIGH_NORMAL_X * REMOTE_CRANK_LENGTH,
ROOT_AXIS[1] + THIGH_NORMAL_Y * REMOTE_CRANK_LENGTH,
0.0,
)
DISTAL_PUSHROD_PIN = (
KNEE_AXIS[0] + THIGH_NORMAL_X * DISTAL_CRANK_LENGTH,
KNEE_AXIS[1] + THIGH_NORMAL_Y * DISTAL_CRANK_LENGTH,
0.0,
)
# Shared Example 20 external interfaces. The actuator uses M3 tapped output
# holes; the leg-side plates use ISO-style clearance holes and socket-head
# counterbores at exactly the same centers.
ACTUATOR_OUTPUT_CONNECTOR_Z = ACTUATOR_OUTPUT_FACE_Z
ACTUATOR_CASE_OUTER_RADIUS = ACTUATOR_PACKAGE_RADIUS
OUTPUT_FLANGE_OUTER_RADIUS = 22.4
OUTPUT_FLANGE_REGISTER_INNER_RADIUS = OUTPUT_REGISTER_RADIUS
OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS = OUTPUT_BOLT_CIRCLE_RADIUS
OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS = 1.65
OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS = 3.0
OUTPUT_FLANGE_SCREW_SHANK_RADIUS = 1.5
OUTPUT_FLANGE_BOLT_COUNT = OUTPUT_BOLT_COUNT
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES = OUTPUT_BOLT_ANGLES_DEGREES
CASE_CLAMP_INNER_RADIUS = ACTUATOR_CASE_OUTER_RADIUS + 0.15
CASE_CLAMP_OUTER_RADIUS = 30.5
CASE_CLAMP_WIDTH = 8.0
CASE_CLAMP_SLIT_WIDTH = 2.2
CASE_CLAMP_PINCH_HOLE_RADIUS = 2.15
CASE_CLAMP_BOLT_SHANK_RADIUS = 2.0
CASE_CLAMP_PINCH_AXIS_RADIUS = 32.0
CASE_CLAMP_PINCH_HALF_SPAN = 7.0
THIGH_CASE_CLAMP_Z = ROOT_AXIS[2] - (ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z)
KNEE_CASE_CLAMP_Z = KNEE_DRIVE_OUTPUT_Z - (
ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z
)
WHEEL_CASE_CLAMP_Z = WHEEL_AXIS[2] - (
ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z
)
PIN_CLEARANCE_RADIUS = 3.2
ROD_PIN_CLEARANCE_RADIUS = 2.2
LIGHTENING_CORNER_RADIUS = 4.0
UPPER_LINK_Z = 2.5
UPPER_LINK_THICKNESS = 5.0
UPPER_LINK_ROOT_RADIUS = 30.5
UPPER_LINK_KNEE_RADIUS = 17.0
UPPER_LINK_WEB_WIDTH = 28.0
UPPER_LINK_WINDOW_LENGTH = 28.0
UPPER_LINK_WINDOW_WIDTH = 14.0
KNEE_BEARING_OUTER_RADIUS = 14.0
KNEE_BEARING_BORE_RADIUS = 6.0
SHANK_LINK_Z = 10.5
SHANK_LINK_THICKNESS = 5.0
SHANK_KNEE_RADIUS = 18.0
SHANK_WHEEL_RADIUS = 31.5
SHANK_WEB_WIDTH = 24.0
SHANK_WINDOW_LENGTH = 25.0
SHANK_WINDOW_WIDTH = 12.0
REMOTE_CRANK_Z = 88.1
REMOTE_CRANK_THICKNESS = 5.0
REMOTE_CRANK_WIDTH = 12.0
DISTAL_CRANK_Z = 88.1
DISTAL_CRANK_THICKNESS = 5.0
DISTAL_CRANK_WIDTH = 12.0
PUSHROD_Z = 83.1
PUSHROD_THICKNESS = 3.6
PUSHROD_WIDTH = 9.0
WHEEL_TIRE_RADIUS = 48.0
WHEEL_TIRE_WIDTH = 18.0
WHEEL_TIRE_BORE_RADIUS = 34.0
WHEEL_HUB_PLATE_RADIUS = 24.5
WHEEL_HUB_PLATE_THICKNESS = 5.0
WHEEL_SPOKE_WIDTH = 5.5
WHEEL_SPOKE_COUNT = 8
BODY_STANDOFF_Z = 22.0
BODY_STANDOFF_THICKNESS = 8.0
KNEE_STACK_CLAMP_Z = KNEE_CASE_CLAMP_Z
KNEE_STACK_CLAMP_THICKNESS = CASE_CLAMP_WIDTH
assert len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES) == OUTPUT_FLANGE_BOLT_COUNT
assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS == 17.0
assert OUTPUT_FLANGE_REGISTER_INNER_RADIUS == 7.98
assert OUTPUT_REGISTER_HEIGHT == 1.5
assert CASE_CLAMP_INNER_RADIUS > ACTUATOR_CASE_OUTER_RADIUS
assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS + OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS < 21.0
def validate_leg_interface_dimensions() -> None:
"""Validate shared actuator/leg hole, register, and clamp clearances."""
generated_angles = tuple(
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES[0]
+ 360.0 * index / OUTPUT_FLANGE_BOLT_COUNT
for index in range(OUTPUT_FLANGE_BOLT_COUNT)
)
assert generated_angles == OUTPUT_FLANGE_BOLT_ANGLES_DEGREES
assert OUTPUT_TAP_RADIUS < OUTPUT_FLANGE_SCREW_SHANK_RADIUS
assert OUTPUT_FLANGE_SCREW_SHANK_RADIUS < OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS
assert OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS > OUTPUT_FLANGE_SCREW_SHANK_RADIUS
assert CASE_CLAMP_BOLT_SHANK_RADIUS < CASE_CLAMP_PINCH_HOLE_RADIUS
assert abs(CASE_CLAMP_INNER_RADIUS - ACTUATOR_CASE_OUTER_RADIUS - 0.15) < 1.0e-9
print(
"leg_interface_dimensions: "
f"output_holes={OUTPUT_FLANGE_BOLT_COUNT} "
f"pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"tap_d={OUTPUT_TAP_RADIUS * 2.0:.2f} "
f"screw_d={OUTPUT_FLANGE_SCREW_SHANK_RADIUS * 2.0:.2f} "
f"clearance_d={OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS * 2.0:.2f} "
f"pilot_d={OUTPUT_FLANGE_REGISTER_INNER_RADIUS * 2.0:.2f}"
)
@@ -0,0 +1,61 @@
"""Materials for the leg-wheel robot dog leg example."""
from __future__ import annotations
import simplecadapi as scad
def make_leg_materials_rdict() -> dict[str, scad.Material]:
"""Create reusable material definitions for the leg assembly."""
return {
"link": scad.make_material_rmaterial(
material_id="leg_link_turquoise_7075",
name="Turquoise anodized 7075 link plates",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.02, 0.67, 0.78),
),
"linkage": scad.make_material_rmaterial(
material_id="leg_linkage_orange_ti",
name="Orange anodized titanium linkage hardware",
density=4.43e-6,
density_unit="kg/mm^3",
color=(0.95, 0.25, 0.06),
),
"bracket": scad.make_material_rmaterial(
material_id="leg_bracket_violet_7075",
name="Violet anodized 7075 actuator clamps",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.48, 0.19, 0.76),
),
"wheel_hub": scad.make_material_rmaterial(
material_id="leg_wheel_hub_gold_7075",
name="Gold anodized 7075 wheel hub",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.94, 0.62, 0.05),
),
"fastener": scad.make_material_rmaterial(
material_id="leg_fastener_black_12_9_steel",
name="Black oxide class 12.9 fastener steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.08, 0.10, 0.13),
),
"bushing": scad.make_material_rmaterial(
material_id="leg_bushing_bronze",
name="Oil-impregnated bearing bronze",
density=8.80e-6,
density_unit="kg/mm^3",
color=(0.63, 0.34, 0.12),
),
"tire": scad.make_material_rmaterial(
material_id="leg_wheel_rubber",
name="Dark rubber tire",
density=1.15e-6,
density_unit="kg/mm^3",
color=(0.025, 0.03, 0.035),
),
}
@@ -0,0 +1,592 @@
"""Bolt-aligned link, crank, pushrod, shank, and wheel parts for Example 18."""
from __future__ import annotations
import math
import simplecadapi as scad
from leg_common import (
make_bolt_circle_cutters_rsolidlist,
make_part_with_connectors_rpart,
make_rounded_bar_rsolid,
make_rounded_slot_cutter_rsolid,
)
from brackets import make_split_case_clamp_rsolid
from leg_dimensions import (
CASE_CLAMP_PINCH_AXIS_RADIUS,
CASE_CLAMP_PINCH_HALF_SPAN,
CASE_CLAMP_WIDTH,
DISTAL_CRANK_LENGTH,
DISTAL_CRANK_THICKNESS,
DISTAL_CRANK_WIDTH,
DISTAL_CRANK_Z,
DISTAL_PUSHROD_PIN,
KNEE_AXIS,
KNEE_BEARING_BORE_RADIUS,
KNEE_BEARING_OUTER_RADIUS,
KNEE_DRIVE_AXIS,
KNEE_PIVOT_Z,
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
OUTPUT_FLANGE_OUTER_RADIUS,
OUTPUT_FLANGE_REGISTER_INNER_RADIUS,
PIN_CLEARANCE_RADIUS,
PROXIMAL_PUSHROD_PIN,
PUSHROD_THICKNESS,
PUSHROD_WIDTH,
PUSHROD_Z,
REMOTE_CRANK_LENGTH,
REMOTE_CRANK_THICKNESS,
REMOTE_CRANK_WIDTH,
REMOTE_CRANK_Z,
ROD_PIN_AXIS_Z,
ROD_PIN_CLEARANCE_RADIUS,
ROOT_AXIS,
SHANK_KNEE_RADIUS,
SHANK_LENGTH,
SHANK_LINK_THICKNESS,
SHANK_LINK_Z,
SHANK_WEB_WIDTH,
SHANK_WHEEL_RADIUS,
SHANK_WINDOW_LENGTH,
SHANK_WINDOW_WIDTH,
UPPER_LINK_KNEE_RADIUS,
UPPER_LINK_ROOT_RADIUS,
UPPER_LINK_THICKNESS,
UPPER_LINK_WEB_WIDTH,
UPPER_LINK_WINDOW_LENGTH,
UPPER_LINK_WINDOW_WIDTH,
UPPER_LINK_Z,
WHEEL_AXIS,
WHEEL_HUB_PLATE_RADIUS,
WHEEL_HUB_PLATE_THICKNESS,
WHEEL_SPOKE_COUNT,
WHEEL_SPOKE_WIDTH,
WHEEL_TIRE_BORE_RADIUS,
WHEEL_TIRE_RADIUS,
WHEEL_TIRE_WIDTH,
WHEEL_CASE_CLAMP_Z,
)
def make_upper_link_plate_rpart(*, material: scad.Material) -> scad.Part:
"""Build the output-bolted upper link plate with knee bearing holes."""
plate = _make_axis_plate_base_rsolid(
start=ROOT_AXIS,
end=KNEE_AXIS,
z_center=UPPER_LINK_Z,
thickness=UPPER_LINK_THICKNESS,
start_radius=UPPER_LINK_ROOT_RADIUS,
end_radius=UPPER_LINK_KNEE_RADIUS,
web_width=UPPER_LINK_WEB_WIDTH,
tag="role.upper_link_plate_base",
)
z_min = UPPER_LINK_Z - UPPER_LINK_THICKNESS / 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=UPPER_LINK_THICKNESS + 2.0,
bottom_face_center=(ROOT_AXIS[0], ROOT_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=KNEE_BEARING_BORE_RADIUS,
height=UPPER_LINK_THICKNESS + 2.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=ROOT_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=z_min - 1.0,
height=UPPER_LINK_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=z_min + UPPER_LINK_THICKNESS,
)
)
cutters.extend(
_make_link_window_cutters(
start=ROOT_AXIS,
end=KNEE_AXIS,
z_center=UPPER_LINK_Z,
thickness=UPPER_LINK_THICKNESS,
fractions=(0.40, 0.67),
length=UPPER_LINK_WINDOW_LENGTH,
width=UPPER_LINK_WINDOW_WIDTH,
tag_prefix="upper_link_window",
)
)
plate = scad.cut_rsolid(plate, cutters, skip_non_intersecting=False)
plate = scad.apply_tag(shape=plate, tag="role.upper_link_plate")
print(
f"upper_link_plate: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"knee_bushing_bore={KNEE_BEARING_BORE_RADIUS * 2.0:.1f} faces={len(plate.get_faces())} "
f"volume={plate.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="upper_link_plate",
body=plate,
name="Upper link plate bolted to actuator output flange and knee bearing retainer",
material=material,
connectors=(
("output_axis", ROOT_AXIS, "z", "Actuator output flange datum"),
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee bearing datum"),
*_output_bolt_connectors(center=ROOT_AXIS, face_z=z_min + UPPER_LINK_THICKNESS, axis="z"),
),
)
def make_proximal_crank_rpart(*, material: scad.Material) -> scad.Part:
"""Create the short crank on the independent knee-drive actuator output."""
crank = _make_axis_plate_base_rsolid(
start=KNEE_DRIVE_AXIS,
end=PROXIMAL_PUSHROD_PIN,
z_center=REMOTE_CRANK_Z,
thickness=REMOTE_CRANK_THICKNESS,
start_radius=OUTPUT_FLANGE_OUTER_RADIUS,
end_radius=REMOTE_CRANK_WIDTH / 2.0 + 2.0,
web_width=REMOTE_CRANK_WIDTH,
tag="role.proximal_output_crank_base",
)
z_min = REMOTE_CRANK_Z - REMOTE_CRANK_THICKNESS / 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=REMOTE_CRANK_THICKNESS + 2.0,
bottom_face_center=(KNEE_DRIVE_AXIS[0], KNEE_DRIVE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=ROD_PIN_CLEARANCE_RADIUS,
height=REMOTE_CRANK_THICKNESS + 2.0,
bottom_face_center=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=KNEE_DRIVE_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=z_min - 1.0,
height=REMOTE_CRANK_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=z_min + REMOTE_CRANK_THICKNESS,
)
)
crank = scad.cut_rsolid(crank, cutters, skip_non_intersecting=False)
crank = scad.apply_tag(shape=crank, tag="role.proximal_output_crank")
print(
f"knee_drive_output_crank: length={REMOTE_CRANK_LENGTH:.1f} "
f"output_flange_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"faces={len(crank.get_faces())} volume={crank.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="proximal_output_crank",
body=crank,
name="Knee-drive crank plate using the second actuator output flange holes",
material=material,
connectors=(
("output_axis", KNEE_DRIVE_AXIS, "z", "Knee-drive actuator output flange datum"),
("rod_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal pushrod pin datum"),
*_output_bolt_connectors(
center=KNEE_DRIVE_AXIS,
face_z=z_min + REMOTE_CRANK_THICKNESS,
axis="z",
),
),
)
def make_pushrod_rpart(*, material: scad.Material) -> scad.Part:
"""Create the flat pushrod with real pin-clearance holes."""
pushrod = make_rounded_bar_rsolid(
start=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], PUSHROD_Z),
end=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], PUSHROD_Z),
width=PUSHROD_WIDTH,
thickness=PUSHROD_THICKNESS,
end_hole_radius=ROD_PIN_CLEARANCE_RADIUS,
lightening_hole_radius=None,
lightening_count=0,
tag="role.knee_pushrod_plate",
)
print(
f"knee_pushrod: pin_distance={_xy_distance(PROXIMAL_PUSHROD_PIN, DISTAL_PUSHROD_PIN):.1f} "
f"pin_hole_diameter={ROD_PIN_CLEARANCE_RADIUS * 2.0:.1f}"
)
return make_part_with_connectors_rpart(
part_id="knee_pushrod",
body=pushrod,
name="Flat pushrod plate with matched clevis pin holes",
material=material,
connectors=(
("proximal_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal crank pin datum"),
("distal_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank ear pin datum"),
),
)
def make_shank_link_rpart(*, material: scad.Material) -> scad.Part:
"""Build the lower shank plate with an integral pushrod extension ear."""
shank = _make_axis_plate_base_rsolid(
start=KNEE_AXIS,
end=WHEEL_AXIS,
z_center=SHANK_LINK_Z,
thickness=SHANK_LINK_THICKNESS,
start_radius=SHANK_KNEE_RADIUS,
end_radius=SHANK_WHEEL_RADIUS,
web_width=SHANK_WEB_WIDTH,
tag="role.shank_link_base",
)
z_min = SHANK_LINK_Z - SHANK_LINK_THICKNESS / 2.0
distal_z_min = DISTAL_CRANK_Z - DISTAL_CRANK_THICKNESS / 2.0
distal_z_top = distal_z_min + DISTAL_CRANK_THICKNESS
distal_drive_ear = _make_axis_plate_base_rsolid(
start=KNEE_AXIS,
end=DISTAL_PUSHROD_PIN,
z_center=DISTAL_CRANK_Z,
thickness=DISTAL_CRANK_THICKNESS,
start_radius=KNEE_BEARING_OUTER_RADIUS,
end_radius=DISTAL_CRANK_WIDTH / 2.0 + 2.0,
web_width=DISTAL_CRANK_WIDTH,
tag="role.shank_integral_pushrod_ear_base",
)
standoff_outer = scad.make_cylinder_rsolid(
radius=KNEE_BEARING_OUTER_RADIUS,
height=distal_z_top - z_min,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min),
axis=(0.0, 0.0, 1.0),
)
standoff_inner = scad.make_cylinder_rsolid(
radius=KNEE_BEARING_BORE_RADIUS,
height=distal_z_top - z_min + 2.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
knee_standoff = scad.cut_rsolid(standoff_outer, standoff_inner, skip_non_intersecting=False)
shank = scad.union_rsolid([shank, distal_drive_ear, knee_standoff], glue=False)
wheel_clamp = make_split_case_clamp_rsolid(
center=WHEEL_AXIS,
z_center=WHEEL_CASE_CLAMP_Z,
tag="role.wheel_actuator_split_clamp",
)
clamp_z_min = WHEEL_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0
clamp_post_height = z_min + SHANK_LINK_THICKNESS - clamp_z_min
clamp_posts = [
scad.make_cylinder_rsolid(
radius=3.8,
height=clamp_post_height,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1] + sign * 29.5, clamp_z_min),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
shank = scad.union_rsolid(shank, wheel_clamp, clamp_posts, glue=False)
integrated_knee_height = distal_z_top - z_min + 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=PIN_CLEARANCE_RADIUS + 2.8,
height=integrated_knee_height,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=ROD_PIN_CLEARANCE_RADIUS,
height=DISTAL_CRANK_THICKNESS + 2.0,
bottom_face_center=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], distal_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=19.5,
height=SHANK_LINK_THICKNESS + 2.0,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
_make_link_window_cutters(
start=KNEE_AXIS,
end=WHEEL_AXIS,
z_center=SHANK_LINK_Z,
thickness=SHANK_LINK_THICKNESS,
fractions=(0.42, 0.66),
length=SHANK_WINDOW_LENGTH,
width=SHANK_WINDOW_WIDTH,
tag_prefix="shank_window",
)
)
shank = scad.cut_rsolid(shank, cutters, skip_non_intersecting=False)
shank = scad.apply_tag(shape=shank, tag="role.shank_wheel_plate")
print(
"shank_link: wheel_case_mount=split_clamp "
f"clamp_bore_d={50.3:.1f} "
f"integral_pushrod_ear={DISTAL_CRANK_LENGTH:.1f} "
f"length={SHANK_LENGTH:.1f} faces={len(shank.get_faces())} volume={shank.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="shank_link",
body=shank,
name="Lower shank plate with integral pushrod ear and wheel actuator split clamp",
material=material,
connectors=(
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee revolute datum"),
("rod_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank pushrod pin datum"),
(
"wheel_case_axis",
(WHEEL_AXIS[0], WHEEL_AXIS[1], WHEEL_CASE_CLAMP_Z),
"z",
"Wheel hub actuator split-clamp datum",
),
(
"wheel_clamp_bolt_seat",
(
WHEEL_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
WHEEL_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
WHEEL_CASE_CLAMP_Z,
),
"y",
"Wheel collar M4 bolt head seat",
),
),
)
def make_wheel_tire_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rubber tire ring as a separate serviceable part."""
tire_center_z = -1.5
tire_outer = scad.make_cylinder_rsolid(
radius=WHEEL_TIRE_RADIUS,
height=WHEEL_TIRE_WIDTH,
bottom_face_center=(
WHEEL_AXIS[0],
WHEEL_AXIS[1],
tire_center_z - WHEEL_TIRE_WIDTH / 2.0,
),
axis=(0.0, 0.0, 1.0),
)
tire_bore = scad.make_cylinder_rsolid(
radius=WHEEL_TIRE_BORE_RADIUS,
height=WHEEL_TIRE_WIDTH + 2.0,
bottom_face_center=(
WHEEL_AXIS[0],
WHEEL_AXIS[1],
tire_center_z - WHEEL_TIRE_WIDTH / 2.0 - 1.0,
),
axis=(0.0, 0.0, 1.0),
)
tire_ring = scad.cut_rsolid(tire_outer, tire_bore, skip_non_intersecting=False)
tire_ring = scad.apply_tag(shape=tire_ring, tag="role.replaceable_rubber_tire")
print(
f"wheel_tire: tire_radius={WHEEL_TIRE_RADIUS:.1f} width={WHEEL_TIRE_WIDTH:.1f} "
f"faces={len(tire_ring.get_faces())} volume={tire_ring.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="wheel_tire",
body=tire_ring,
name="Replaceable rubber wheel tire ring",
material=material,
connectors=(("hub_axis", WHEEL_AXIS, "z", "Wheel hub overmold datum"),),
)
def make_wheel_hub_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rigid 7075 hub and spokes bolted to the actuator flange."""
hub_z = WHEEL_HUB_PLATE_THICKNESS / 2.0
hub_z_min = hub_z - WHEEL_HUB_PLATE_THICKNESS / 2.0
hub = scad.make_cylinder_rsolid(
radius=WHEEL_HUB_PLATE_RADIUS,
height=WHEEL_HUB_PLATE_THICKNESS,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min),
axis=(0.0, 0.0, 1.0),
)
spokes = [
_make_wheel_spoke_rsolid(
angle_degrees=22.5 + 360.0 * index / WHEEL_SPOKE_COUNT
)
for index in range(WHEEL_SPOKE_COUNT)
]
wheel = scad.union_rsolid([hub, spokes], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=WHEEL_HUB_PLATE_THICKNESS + 2.0,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=WHEEL_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=hub_z_min - 1.0,
height=WHEEL_HUB_PLATE_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS,
)
)
wheel = scad.cut_rsolid(wheel, cutters, skip_non_intersecting=False)
wheel = scad.apply_tag(shape=wheel, tag="role.rigid_spoked_wheel_hub")
print(
f"wheel_hub: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} spokes={WHEEL_SPOKE_COUNT} "
f"faces={len(wheel.get_faces())} volume={wheel.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="wheel_hub",
body=wheel,
name="7075 spoked wheel hub bolted to the actuator output flange",
material=material,
connectors=(
("wheel_axis", WHEEL_AXIS, "z", "Wheel spin datum"),
("tire_axis", WHEEL_AXIS, "z", "Replaceable tire datum"),
*_output_bolt_connectors(
center=WHEEL_AXIS,
face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS,
axis="z",
),
),
)
def _make_axis_plate_base_rsolid(
*,
start: tuple[float, float, float],
end: tuple[float, float, float],
z_center: float,
thickness: float,
start_radius: float,
end_radius: float,
web_width: float,
tag: str,
) -> scad.Solid:
length = _xy_distance(start, end)
if length <= max(start_radius, end_radius):
raise ValueError("axis plate endpoints are too close")
z_min = z_center - thickness / 2.0
web = scad.make_box_rsolid(
width=length,
height=web_width,
depth=thickness,
bottom_face_center=(length / 2.0, 0.0, z_min),
)
start_boss = scad.make_cylinder_rsolid(
radius=start_radius,
height=thickness,
bottom_face_center=(0.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
end_boss = scad.make_cylinder_rsolid(
radius=end_radius,
height=thickness,
bottom_face_center=(length, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
plate = scad.union_rsolid([web, start_boss, end_boss], glue=False)
angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0]))
plate = scad.rotate_shape(
shape=plate,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, z_center),
)
plate = scad.translate_shape(shape=plate, vector=(start[0], start[1], 0.0))
return scad.apply_tag(shape=plate, tag=tag)
def _make_link_window_cutters(
*,
start: tuple[float, float, float],
end: tuple[float, float, float],
z_center: float,
thickness: float,
fractions: tuple[float, ...],
length: float,
width: float,
tag_prefix: str,
) -> list[scad.Solid]:
angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0]))
cutters = []
for index, fraction in enumerate(fractions, start=1):
cutters.append(
make_rounded_slot_cutter_rsolid(
center=(
start[0] + (end[0] - start[0]) * fraction,
start[1] + (end[1] - start[1]) * fraction,
z_center,
),
length=length,
width=width,
height=thickness + 2.0,
angle_degrees=angle_degrees,
tag=f"role.{tag_prefix}_{index}",
)
)
return cutters
def _make_wheel_spoke_rsolid(*, angle_degrees: float) -> scad.Solid:
hub_overlap_radius = WHEEL_HUB_PLATE_RADIUS - 1.5
rim_overlap_radius = WHEEL_TIRE_BORE_RADIUS + 1.5
length = rim_overlap_radius - hub_overlap_radius
radial_center = (hub_overlap_radius + rim_overlap_radius) / 2.0
z_center = WHEEL_HUB_PLATE_THICKNESS / 2.0
spoke = scad.make_box_rsolid(
width=length,
height=WHEEL_SPOKE_WIDTH,
depth=WHEEL_HUB_PLATE_THICKNESS,
bottom_face_center=(WHEEL_AXIS[0] + radial_center, WHEEL_AXIS[1], z_center - WHEEL_HUB_PLATE_THICKNESS / 2.0),
)
return scad.rotate_shape(
shape=spoke,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=WHEEL_AXIS,
)
def _xy_distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def _output_bolt_connectors(
*, center: tuple[float, float, float], face_z: float, axis: str
) -> tuple[tuple[str, tuple[float, float, float], str, str], ...]:
connectors = []
for index, angle_degrees in enumerate(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, start=1):
angle = math.radians(angle_degrees)
connectors.append(
(
f"output_bolt_{index}_head_top",
(
center[0] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.cos(angle),
center[1] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.sin(angle),
face_z,
),
axis,
f"M3 output screw {index} head top",
)
)
return tuple(connectors)
@@ -0,0 +1,90 @@
"""Build, validate, and export the leg-wheel robot dog leg example."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import simplecadapi as scad
from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly
from actuator import make_actuator_materials_rdict
from leg_common import ground_compound
from leg_materials import make_leg_materials_rdict
from leg_dimensions import validate_leg_interface_dimensions
# Example 16's reducer graph is intentionally deep because of herringbone gears.
sys.setrecursionlimit(40000)
OUT_DIR = Path("examples/out/leg_wheel_robot_dog_leg")
def _build_leg_wheel_robot_dog_leg():
validate_leg_interface_dimensions()
actuator_materials = make_actuator_materials_rdict()
leg_materials = make_leg_materials_rdict()
with scad.GraphSession(graph_id="leg_wheel_robot_dog_leg") as session:
assembly = make_leg_wheel_robot_dog_leg_rassembly(
actuator_materials=actuator_materials,
leg_materials=leg_materials,
)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
ground_compound(label="leg_preview", compound=preview)
leaf_ops = [node.op for node in session.graph.leaf_nodes()]
print(f"leg_graph_results: leaves={len(leaf_ops)} ops={','.join(leaf_ops)}")
if leaf_ops != ["make_compound_from_assembly_rcompound"]:
raise RuntimeError("Leg graph contains detached source results")
session_json = scad.export_session_json(session=session)
model_json = scad.export_model_json(session=session)
return assembly, preview, model_json, session_json
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUT_DIR / "leg_wheel_robot_dog_leg.model.json"
session_path = OUT_DIR / "leg_wheel_robot_dog_leg.session.json"
step_path = OUT_DIR / "leg_wheel_robot_dog_leg.step"
fcstd_path = OUT_DIR / "leg_wheel_robot_dog_leg.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json, session_json = _build_leg_wheel_robot_dog_leg()
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
imported = scad.import_model_json(json_str=model_json)
replayed = scad.replay_model_json(json_str=model_json)
payload = json.loads(model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
output_path=str(fcstd_path.resolve()),
document_name="LegWheelRobotDogLeg",
freecad_cmd=None,
)
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})"
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print("constraints=" + ",".join(assembly.constraint_ids()))
print(f"preview_solids={len(preview.get_solids())}")
print(f"preview_volume={preview.get_volume():.3f}")
print(f"imported_keys={','.join(sorted(imported.keys()))}")
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"session={session_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,163 @@
"""Assembly and kinematic constraints for a four-planet planetary reducer."""
from __future__ import annotations
import simplecadapi as scad
from dimensions import (
FIXED_RING_REDUCTION,
PLANET_COUNT,
PLANET_PITCH_RADIUS,
RING_PITCH_RADIUS,
SUN_PITCH_RADIUS,
)
from materials import make_materials_rdict
from parts import (
make_carrier_rpart,
make_planet_component_rplacement,
make_planet_gear_rpart,
make_ring_gear_rpart,
make_sun_gear_rpart,
)
def make_four_planet_planetary_reducer_rassembly() -> scad.Assembly:
"""Build and solve the exposed four-planet fixed-ring reducer gearset."""
print(
f"ratio_plan: fixed_ring={FIXED_RING_REDUCTION:.3f}:1 "
f"planets={PLANET_COUNT} sun_r={SUN_PITCH_RADIUS:.3f} "
f"planet_r={PLANET_PITCH_RADIUS:.3f} ring_r={RING_PITCH_RADIUS:.3f}"
)
materials = make_materials_rdict()
sun = make_sun_gear_rpart(material=materials["gear"])
ring = make_ring_gear_rpart(material=materials["ring"])
planet = make_planet_gear_rpart(material=materials["gear"])
carrier = make_carrier_rpart(material=materials["carrier"])
reducer = scad.make_assembly_rassembly(
assembly_id="four_planet_planetary_reducer",
name="Exposed 3.5:1 four-planet fixed-ring planetary reducer gearset",
)
for component_id, item, placement, name in (
("fixed_ring", ring, scad.identity_placement_rplacement(), "Fixed internal ring gear"),
("sun_input", sun, scad.identity_placement_rplacement(), "Input sun gear"),
("output_carrier", carrier, scad.identity_placement_rplacement(), "Four-pin output carrier"),
):
reducer = scad.add_component_rassembly(
assembly=reducer,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
for index in range(PLANET_COUNT):
reducer = scad.add_component_rassembly(
assembly=reducer,
item=planet,
component_id=f"planet_{index + 1}",
placement=make_planet_component_rplacement(index=index),
name=f"Planet gear {index + 1}",
)
reducer = _add_public_connectors_rassembly(assembly=reducer)
reducer = _add_kinematic_constraints_rassembly(assembly=reducer)
reducer = scad.solve_assembly_constraints_rassembly(assembly=reducer, strict=True)
_ground_constraint_report(assembly=reducer)
print(
f"planetary_components: count={len(reducer.component_ids())} "
f"constraints={len(reducer.constraint_ids())}"
)
return reducer
def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
forwarded = (
("fixed_axis", "fixed_ring", "axis", "Fixed ring datum"),
("input_axis", "sun_input", "axis", "Sun input datum"),
("output_axis", "output_carrier", "output_axis", "Carrier output datum"),
)
for connector_id, component_id, source_connector_id, name in forwarded:
assembly = scad.forward_connector_rassembly(
assembly=assembly,
connector_id=connector_id,
source_component_id=component_id,
source_connector_id=source_connector_id,
name=name,
)
print("public_connectors: " + ",".join(connector_id for connector_id, *_ in forwarded))
return assembly
def _add_kinematic_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="fixed_ring")
revolutes = (
("sun_input_revolute", "fixed_ring", "axis", "sun_input", "axis", 0.0),
("carrier_output_revolute", "fixed_ring", "axis", "output_carrier", "axis", 0.0),
)
for constraint_id, a_component, a_connector, b_component, b_connector, drive_angle in revolutes:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=_ref(component_id=a_component, connector_id=a_connector),
connector_b=_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=drive_angle,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
for index in range(PLANET_COUNT):
planet_id = f"planet_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"planet_{index + 1}_pin_revolute",
connector_a=_ref(component_id="output_carrier", connector_id=f"planet_{index + 1}_axis"),
connector_b=_ref(component_id=planet_id, connector_id="axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"Planet {index + 1} pin bearing revolute",
)
assembly = scad.add_gear_constraint_rassembly(
assembly=assembly,
constraint_id=f"sun_to_planet_{index + 1}_external_mesh",
connector_a=_ref(component_id="sun_input", connector_id="axis"),
connector_b=_ref(component_id=planet_id, connector_id="axis"),
pitch_radius_a=SUN_PITCH_RADIUS,
pitch_radius_b=PLANET_PITCH_RADIUS,
phase_offset=None,
name=f"Sun external mesh to planet {index + 1}",
)
assembly = scad.add_belt_constraint_rassembly(
assembly=assembly,
constraint_id=f"ring_to_planet_{index + 1}_internal_mesh",
connector_a=_ref(component_id="fixed_ring", connector_id="axis"),
connector_b=_ref(component_id=planet_id, connector_id="axis"),
pulley_radius_a=RING_PITCH_RADIUS,
pulley_radius_b=PLANET_PITCH_RADIUS,
phase_offset=None,
name=f"Fixed ring internal mesh to planet {index + 1}",
)
print(
f"constraints_added: grounded=1 revolute={2 + PLANET_COUNT} "
f"external_mesh={PLANET_COUNT} internal_mesh={PLANET_COUNT}"
)
return assembly
def _ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
def _ground_constraint_report(*, assembly: scad.Assembly) -> None:
report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly)
print(
f"assembly_constraints: solved={report.solved} grounded={len(report.grounded_component_ids)} "
f"solved_components={len(report.solved_component_ids)} unsolved={len(report.unsolved_component_ids)}"
)
for residual in report.residuals:
print(
f"constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}"
)
@@ -0,0 +1,78 @@
"""Shared helpers for Example 19."""
from __future__ import annotations
import math
import simplecadapi as scad
from simplecadapi import ql
def make_z_rotation_rplacement(
*,
origin: tuple[float, float, float],
angle_degrees: float,
) -> scad.Placement:
"""Create a placement rotated about local Z and translated to origin."""
angle = math.radians(angle_degrees)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
)
def add_axis_connector_rpart(
*,
part: scad.Part,
connector_id: str,
origin: tuple[float, float, float],
name: str,
) -> scad.Part:
"""Attach a topology-free Z-axis datum connector to a part."""
connector = scad.make_placement_connector_rconnector(
connector_id=connector_id,
placement=scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 1.0, 0.0),
),
name=name,
)
return scad.add_connector_rpart(part=part, connector=connector)
def make_axis_part_rpart(
*,
part_id: str,
body: scad.Solid,
name: str,
material: scad.Material,
connector_specs: tuple[tuple[str, tuple[float, float, float], str], ...],
) -> scad.Part:
"""Wrap one solid as a part and attach named axis connectors."""
part = scad.make_part_rpart(part_id=part_id, body=body, name=name)
part = scad.assign_material_rpart(part=part, material=material)
for connector_id, origin, connector_name in connector_specs:
part = add_axis_connector_rpart(
part=part,
connector_id=connector_id,
origin=origin,
name=connector_name,
)
print(f"part_{part_id}: connectors={len(part.connectors)} volume={body.get_volume():.3f}")
return part
def ground_solid(*, label: str, solid: scad.Solid) -> None:
"""Print a small QL-grounded summary for a generated solid."""
faces = ql.select(items=solid.get_faces()).all()
edges = ql.select(items=solid.get_edges()).all()
print(
f"{label}: faces={len(faces)} edges={len(edges)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
@@ -0,0 +1,54 @@
"""Design constants for the four-planet single-stage planetary reducer."""
from __future__ import annotations
import math
MODULE = 1.5
PRESSURE_ANGLE = 20.0
GEAR_HEIGHT = 8.0
BACKLASH = 0.04
ADDENDUM_FACTOR = 1.0
CLEARANCE_FACTOR = 0.25
SUN_TEETH = 24
PLANET_TEETH = 18
PLANET_COUNT = 4
RING_TEETH = SUN_TEETH + 2 * PLANET_TEETH
SUN_PITCH_RADIUS = MODULE * SUN_TEETH / 2.0
PLANET_PITCH_RADIUS = MODULE * PLANET_TEETH / 2.0
RING_PITCH_RADIUS = MODULE * RING_TEETH / 2.0
PLANET_CENTER_RADIUS = SUN_PITCH_RADIUS + PLANET_PITCH_RADIUS
FIXED_RING_REDUCTION = 1.0 + RING_TEETH / SUN_TEETH
RING_RIM_THICKNESS = 4.0
GEAR_AXIS_Z = GEAR_HEIGHT / 2.0
SUN_BORE_RADIUS = 3.0
PLANET_PIN_RADIUS = 2.6
PLANET_PIN_CLEARANCE_RADIUS = 3.2
CARRIER_BOTTOM_Z = -5.0
CARRIER_THICKNESS = 4.0
CARRIER_HUB_RADIUS = 10.0
CARRIER_ARM_WIDTH = 6.0
CARRIER_PIN_BOSS_RADIUS = 5.2
CARRIER_PIN_HEIGHT = GEAR_HEIGHT + 6.0
def planet_angle_degrees(*, index: int) -> float:
"""Return the equally spaced carrier angle for one planet index."""
return 360.0 * index / PLANET_COUNT
def planet_center_xy(*, index: int) -> tuple[float, float]:
"""Return the XY pitch-center location for one planet."""
angle = math.radians(planet_angle_degrees(index=index))
return (
PLANET_CENTER_RADIUS * math.cos(angle),
PLANET_CENTER_RADIUS * math.sin(angle),
)
@@ -0,0 +1,71 @@
"""Build, solve, and export the four-planet planetary reducer gearset."""
from __future__ import annotations
import json
from pathlib import Path
import simplecadapi as scad
from assembly import make_four_planet_planetary_reducer_rassembly
OUT_DIR = Path("examples/out/19_four_planet_planetary_reducer")
def _build_four_planet_reducer():
with scad.GraphSession(graph_id="four_planet_planetary_reducer") as session:
assembly = make_four_planet_planetary_reducer_rassembly()
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
session_json = scad.export_session_json(session=session)
model_json = scad.export_model_json(session=session)
return assembly, preview, model_json, session_json
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUT_DIR / "four_planet_planetary_reducer.model.json"
session_path = OUT_DIR / "four_planet_planetary_reducer.session.json"
step_path = OUT_DIR / "four_planet_planetary_reducer.step"
fcstd_path = OUT_DIR / "four_planet_planetary_reducer.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json, session_json = _build_four_planet_reducer()
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
imported = scad.import_model_json(json_str=model_json)
replayed = scad.replay_model_json(json_str=model_json)
payload = json.loads(model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
output_path=str(fcstd_path.resolve()),
document_name="FourPlanetPlanetaryReducer",
freecad_cmd=None,
)
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})"
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print("constraints=" + ",".join(assembly.constraint_ids()))
print(f"preview_solids={len(preview.get_solids())}")
print(f"preview_volume={preview.get_volume():.3f}")
print(f"imported_keys={','.join(sorted(imported.keys()))}")
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"session={session_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
"""Materials for the four-planet planetary reducer example."""
from __future__ import annotations
import simplecadapi as scad
def make_materials_rdict() -> dict[str, scad.Material]:
"""Create simple material definitions for the exposed gearset."""
materials = {
"gear": scad.make_material_rmaterial(
material_id="case_hardened_steel",
name="Case hardened gear steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.70, 0.70, 0.74),
),
"ring": scad.make_material_rmaterial(
material_id="nitrided_internal_ring_steel",
name="Nitrided internal ring steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.44, 0.46, 0.50),
),
"carrier": scad.make_material_rmaterial(
material_id="aluminum_7075_t6",
name="7075-T6 aluminum carrier",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.14, 0.48, 0.70),
),
}
print("materials: " + ",".join(sorted(materials)))
return materials
@@ -0,0 +1,206 @@
"""Gear and carrier parts for the four-planet planetary reducer."""
from __future__ import annotations
import simplecadapi as scad
from common import ground_solid, make_axis_part_rpart, make_z_rotation_rplacement
from dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CARRIER_ARM_WIDTH,
CARRIER_BOTTOM_Z,
CARRIER_HUB_RADIUS,
CARRIER_PIN_BOSS_RADIUS,
CARRIER_PIN_HEIGHT,
CARRIER_THICKNESS,
CLEARANCE_FACTOR,
GEAR_AXIS_Z,
GEAR_HEIGHT,
MODULE,
PLANET_CENTER_RADIUS,
PLANET_COUNT,
PLANET_PIN_CLEARANCE_RADIUS,
PLANET_PIN_RADIUS,
PLANET_TEETH,
PRESSURE_ANGLE,
RING_RIM_THICKNESS,
RING_TEETH,
SUN_BORE_RADIUS,
SUN_TEETH,
planet_angle_degrees,
planet_center_xy,
)
def make_sun_gear_rpart(*, material: scad.Material) -> scad.Part:
"""Create the input sun gear with a service bore and axis connector."""
sun = scad.std.gear.make_spur_gear_rsolid(
n_teeth=SUN_TEETH,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
bore = scad.make_cylinder_rsolid(
radius=SUN_BORE_RADIUS,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
sun = scad.cut_rsolid(sun, bore, skip_non_intersecting=False)
sun = scad.apply_tag(shape=sun, tag="role.sun_input_gear")
ground_solid(label="sun_gear", solid=sun)
return make_axis_part_rpart(
part_id="sun_input_gear",
body=sun,
name="Input sun gear, 24 teeth",
material=material,
connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Sun input axis"),),
)
def make_ring_gear_rpart(*, material: scad.Material) -> scad.Part:
"""Create the fixed internal ring gear without an enclosing housing."""
ring = scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=RING_TEETH,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
gear_height=GEAR_HEIGHT,
rim_thickness=RING_RIM_THICKNESS,
backlash=BACKLASH,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
)
ring = scad.apply_tag(shape=ring, tag="role.fixed_internal_ring_gear")
ground_solid(label="ring_gear", solid=ring)
return make_axis_part_rpart(
part_id="fixed_ring_gear",
body=ring,
name="Fixed internal ring gear, 60 teeth",
material=material,
connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Fixed ring axis"),),
)
def make_planet_gear_rpart(*, material: scad.Material) -> scad.Part:
"""Create one reusable planet gear with a carrier-pin bore."""
planet = scad.std.gear.make_spur_gear_rsolid(
n_teeth=PLANET_TEETH,
module=MODULE,
pressure_angle=PRESSURE_ANGLE,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
bore = scad.make_cylinder_rsolid(
radius=PLANET_PIN_CLEARANCE_RADIUS,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
planet = scad.cut_rsolid(planet, bore, skip_non_intersecting=False)
planet = scad.apply_tag(shape=planet, tag="role.reusable_planet_gear")
ground_solid(label="planet_gear", solid=planet)
return make_axis_part_rpart(
part_id="planet_gear",
body=planet,
name="Reusable planet gear, 18 teeth",
material=material,
connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Planet spin axis"),),
)
def make_carrier_rpart(*, material: scad.Material) -> scad.Part:
"""Create the four-pin carrier output spider."""
hub = scad.make_cylinder_rsolid(
radius=CARRIER_HUB_RADIUS,
height=CARRIER_THICKNESS,
bottom_face_center=(0.0, 0.0, CARRIER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
arms: list[scad.Solid] = []
pin_bosses: list[scad.Solid] = []
pins: list[scad.Solid] = []
for index in range(PLANET_COUNT):
angle = planet_angle_degrees(index=index)
x, y = planet_center_xy(index=index)
arm = scad.make_box_rsolid(
width=PLANET_CENTER_RADIUS + CARRIER_PIN_BOSS_RADIUS,
height=CARRIER_ARM_WIDTH,
depth=CARRIER_THICKNESS,
bottom_face_center=(PLANET_CENTER_RADIUS / 2.0, 0.0, CARRIER_BOTTOM_Z),
)
arms.append(
scad.rotate_shape(
shape=arm,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
pin_bosses.append(
scad.make_cylinder_rsolid(
radius=CARRIER_PIN_BOSS_RADIUS,
height=CARRIER_THICKNESS,
bottom_face_center=(x, y, CARRIER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
pins.append(
scad.make_cylinder_rsolid(
radius=PLANET_PIN_RADIUS,
height=CARRIER_PIN_HEIGHT,
bottom_face_center=(x, y, CARRIER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
carrier = scad.union_rsolid([hub, arms, pin_bosses, pins], glue=False)
center_bore = scad.make_cylinder_rsolid(
radius=SUN_BORE_RADIUS + 0.8,
height=CARRIER_THICKNESS + 2.0,
bottom_face_center=(0.0, 0.0, CARRIER_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
carrier = scad.cut_rsolid(carrier, center_bore, skip_non_intersecting=False)
carrier = scad.apply_tag(shape=carrier, tag="role.four_pin_output_carrier")
ground_solid(label="carrier", solid=carrier)
connector_specs = [
("axis", (0.0, 0.0, GEAR_AXIS_Z), "Carrier output axis"),
("output_axis", (0.0, 0.0, GEAR_AXIS_Z), "Public output axis"),
]
connector_specs.extend(
(
f"planet_{index + 1}_axis",
(*planet_center_xy(index=index), GEAR_AXIS_Z),
f"Planet {index + 1} carrier pin axis",
)
for index in range(PLANET_COUNT)
)
return make_axis_part_rpart(
part_id="four_pin_output_carrier",
body=carrier,
name="Four-pin output carrier spider",
material=material,
connector_specs=tuple(connector_specs),
)
def make_planet_component_rplacement(*, index: int) -> scad.Placement:
"""Return the placement for one of the four equally spaced planets."""
angle = planet_angle_degrees(index=index)
x, y = planet_center_xy(index=index)
tooth_phase = angle + 180.0 - (180.0 / PLANET_TEETH)
print(
f"planet_{index + 1}: center=({x:.3f},{y:.3f},0.000) "
f"carrier_angle={angle:.1f} spin_phase={tooth_phase:.1f}"
)
return make_z_rotation_rplacement(origin=(x, y, 0.0), angle_degrees=tooth_phase)
@@ -0,0 +1,139 @@
# Case 20: Integrated 50 mm BLDC Joint Actuator
## Classification
Product-level kinematic assembly. The actuator contains separately manufactured
electrical, magnetic, bearing, gear, housing, and output parts. The fixed housing
is the ground; the rotor, two carriers, and six planets are rotating components.
## Design Intent
- Fit a real inner-rotor brushless motor, a 20:1 reducer, a circular controller
PCB, and serviceable wiring terminals into one coaxial 50 mm package.
- Eliminate the separate motor-to-reducer coupler. The rotor shaft and stage-1
sun are one steel solid.
- Preserve a short load path from the output flange through two adjacent output
bearings into a separate front bearing cap.
- Keep the stator and both ring gears as replaceable press-fit inserts instead of
hiding them inside an impossible one-piece enclosure.
- Reserve rear-facing connector apertures and PCB mounting holes so electronics
are not represented by an empty cosmetic volume.
## Envelope And Performance
| Item | Value |
|---|---:|
| Motor / housing outside diameter | 50.0 mm |
| Structural axial envelope | 75.8 mm |
| Terminal protrusion included | 77.3 mm |
| Motor topology | 12-slot / 14-pole inner rotor |
| Stator active length | 16.0 mm |
| Rotor magnetic length | 17.5 mm |
| Radial air gap | 0.30 mm |
| Stage 1 | 15/15/45 teeth, 4:1 |
| Stage 2 | 18/27/72 teeth, 5:1 |
| Total reduction | 20:1 |
| Reducer housing minimum cylindrical wall | 2.20 mm |
The two stages intentionally use different modules. Stage 1 uses module 0.80 to
leave enough root section around the 8 mm direct-drive shaft. Stage 2 uses module
0.55 to fit the 72-tooth, 5:1 ring inside the 50 mm housing. Both tooth sets obey
the three-planet equal-spacing condition `(sun teeth + ring teeth) mod 3 = 0`.
## Bill Of Materials
| Part / subassembly | Manufacturing intent | Material / connection |
|---|---|---|
| Main reducer housing | One machined fixed part | 6061-T6 aluminum |
| Motor shell | One machined fixed part | 6061-T6, six M3 front screws |
| Rear bearing spider | Separate machined part | 7075-T6, four M2.5 screws |
| Rear electronics cover | Separate machined part with PCB bosses | 6061-T6, four M2.5 screws |
| Output bearing cap | Separate machined part | 7075-T6, six M3 screws |
| Stator core | 12-slot laminated stack | Electrical steel, thermal press fit |
| Windings | Twelve separately represented coil packs | Copper, varnish/potting retained |
| Rotor shaft + stage-1 sun | One integrated machined solid | Hardened alloy steel |
| Rotor magnets | Fourteen bonded inserts | NdFeB |
| PCB | Circular controller board with real holes/notches | FR-4/copper |
| Phase terminal | Three-position rear-access terminal | High-temperature polymer |
| Power/CAN terminal | Four-position rear-access terminal | High-temperature polymer |
| Fixed ring gears | Two replaceable press-fit inserts | Case-hardened steel |
| Planet gears | Six gears with standard bearing seats | Case-hardened steel |
| Stage-1 carrier + stage-2 sun | One integrated interstage part | 7075 carrier / modeled as steel-duty part |
| Output carrier + flange | One integrated output part | 7075-T6 aluminum |
| Motor bearings | 8x16x5 and 8x19x6 | Standard ball bearings |
| Interstage bearing | 5x10x3 | Thin radial ball bearing in fixed divider |
| Planet bearings | Six 3x6x3 bearings | Standard ball bearings |
| Output bearings | Two 16x24x5 bearings | Standard ball bearings |
Fasteners are represented by matching holes and documented interfaces rather
than individual screw solids. This keeps the graph focused while retaining
manufacturable attachment geometry.
## Interface Table
| Constraint / interface | Motion meaning | Real connection | Geometry and clearance |
|---|---|---|---|
| `motor_shell_to_reducer_housing` | Fixed | Six M3 screws | 43.0 mm PCD, 3.2 mm holes |
| `rear_spider_to_motor_shell` | Fixed | Four M2.5 screws | 40.6 mm PCD, 2.7 mm holes; face-to-face column/spider joint |
| `rear_cover_to_motor_shell` | Fixed | Four M2.5 screws | Shared rear columns and holes |
| `stator_to_motor_shell` | Fixed | Thermal press fit + potting | Nominal line-to-line CAD fit; tolerance sets interference |
| `electronics_to_rear_cover` | Fixed | Four M2 PCB screws | 33.0 mm PCD and integrated standoffs |
| `rotor_revolute` | Motor input rotation | Front/rear radial bearings | 8 mm shaft, 0.30 mm magnetic air gap |
| `stage1_ring_fixed` | Fixed ring | Interference fit and axial clamp | 0.04 mm diametral modeled interference |
| `stage1_carrier_revolute` | First reduction output | 5x10x3 radial bearing | Integrated 5 mm stage-2 sun shaft |
| `stage2_ring_fixed` | Fixed ring | Interference fit and axial clamp | 0.04 mm diametral modeled interference |
| `output_carrier_revolute` | Joint output rotation | Paired output bearings | 16 mm shaft in two 16x24x5 bearings |
| Planet revolutes | Planet spin | 3x6x3 bearings on 3 mm pins | 0.05 mm radial gear-seat clearance |
| Output cap to housing | Fixed | Six M3 screws | 43.0 mm PCD, 3.2 mm holes |
| Output link | External fixed attachment | Six M3 screws | 34.0 mm PCD tapped holes, Ø15.96 locating pilot |
## Electronics Packaging
The controller PCB is a 44.4 mm circular board behind the rear motor bearing. It
has a 10 mm center service bore, four M2 mounting holes, four large edge notches
for the rear structural columns, three phase-terminal pin holes, and four
power/CAN pin holes. Two rear-cover apertures expose the terminal bodies without
removing the controller. The board remains removable after the rear cover and
terminal screws are released.
## Assembly Order
1. Press the stator stack into the motor shell and pot the twelve winding packs.
2. Install the rear bearing into the four-arm spider and bolt the spider to the
shell's rear columns.
3. Insert the rotor/shaft from the reducer side and support it with the front
motor bearing in the reducer bulkhead.
4. Bolt the motor shell to the reducer housing with the six-hole front interface.
5. Insert the first fixed ring and planetary stage, the 5x10x3 interstage
bearing, then the second fixed ring and planetary stage from the open front.
6. Install the paired output bearings in the removable output cap, then bolt the
cap to the reducer housing.
7. Install the PCB and terminals on the rear-cover standoffs, connect phases and
sensors, and attach the rear cover.
This sequence avoids the trapped 43 mm ring-gear problem in Case 16: both ring
inserts and carriers enter through the open reducer front before the bearing cap
is installed.
## Strength And Thermal Notes
- The motor shell retains 1.80 mm radial wall around the stator and the reducer
shell retains 2.20 mm around the steel ring inserts.
- The 43 mm PCD case holes pass through 18.5 mm-radius end lands, retaining
1.4 mm of continuous aluminum ligament on the bore side of each M3 clearance
hole instead of clipping only the thin cylindrical shell.
- The front reducer bulkhead is 8 mm long around the 19 mm motor bearing.
- Two adjacent output bearings form a 10 mm stack with 5 mm center spacing to
distribute overturning load.
- The output flange leaves 4.15 mm radial ligament beyond the M3 tapped-hole edges.
- The output face carries a 1.5 mm-high, Ø15.96 locating pilot. Mating links use
the pilot for concentric location and six Ø3.3 clearance holes with Ø6 socket-
head counterbores; the screws provide clamp load rather than radial location.
- External robot structure clamps the continuous Ø50 reducer sleeve at the
`case_clamp_axis` datum (`Z = 20.0`) instead of sharing the internal output-cap
retention screws.
- The stator yoke contacts the aluminum shell over its full active length for a
direct thermal path; controller heat can flow through the rear standoffs and
cover.
- Detailed tooth stress, bearing life, winding thermal limits, rotor retention,
and fastener preload still require engineering calculation and prototype test.
@@ -0,0 +1 @@
"""Reusable implementation package for Example 20."""
@@ -0,0 +1,441 @@
"""Top-level integrated BLDC motor, controller, reducer, and housing assembly."""
from __future__ import annotations
import simplecadapi as scad
try:
from .bearings import (
make_coaxial_bearing_rplacement,
make_main_bearing_rassembly,
make_planet_bearing_rplacement,
make_standard_planet_bearing_rassembly,
)
from .common import connector_ref, ground_constraint_report
from .dimensions import (
FRONT_MOTOR_BEARING,
FRONT_MOTOR_BEARING_CENTER_Z,
INTERSTAGE_BEARING,
INTERSTAGE_BEARING_CENTER_Z,
OUTPUT_BEARING,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
PLANET_BEARING,
PLANET_COUNT,
REAR_BEARING_CENTER_Z,
REAR_MOTOR_BEARING,
STAGE_1,
STAGE_2,
TOTAL_REDUCTION,
StageSpec,
)
from .electronics import make_integrated_controller_rassembly
from .gears import (
make_output_carrier_flange_rpart,
make_planet_rplacement,
make_stage1_carrier_sun_rpart,
make_stage_planet_gear_rpart,
make_stage_ring_gear_rpart,
)
from .housing import (
make_motor_shell_rpart,
make_output_bearing_cap_rpart,
make_rear_bearing_spider_rpart,
make_rear_electronics_cover_rpart,
make_reducer_housing_rpart,
)
from .motor import make_bldc_rotor_rassembly, make_bldc_stator_rassembly
except ImportError: # Support direct execution from this example directory.
from bearings import (
make_coaxial_bearing_rplacement,
make_main_bearing_rassembly,
make_planet_bearing_rplacement,
make_standard_planet_bearing_rassembly,
)
from common import connector_ref, ground_constraint_report
from dimensions import (
FRONT_MOTOR_BEARING,
FRONT_MOTOR_BEARING_CENTER_Z,
INTERSTAGE_BEARING,
INTERSTAGE_BEARING_CENTER_Z,
OUTPUT_BEARING,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
PLANET_BEARING,
PLANET_COUNT,
REAR_BEARING_CENTER_Z,
REAR_MOTOR_BEARING,
STAGE_1,
STAGE_2,
TOTAL_REDUCTION,
StageSpec,
)
from electronics import make_integrated_controller_rassembly
from gears import (
make_output_carrier_flange_rpart,
make_planet_rplacement,
make_stage1_carrier_sun_rpart,
make_stage_planet_gear_rpart,
make_stage_ring_gear_rpart,
)
from housing import (
make_motor_shell_rpart,
make_output_bearing_cap_rpart,
make_rear_bearing_spider_rpart,
make_rear_electronics_cover_rpart,
make_reducer_housing_rpart,
)
from motor import make_bldc_rotor_rassembly, make_bldc_stator_rassembly
def make_integrated_bldc_joint_actuator_rassembly(
*, materials: dict[str, scad.Material]
) -> scad.Assembly:
"""Build and solve the complete compact 50 mm joint actuator."""
component_specs = make_integrated_bldc_joint_actuator_components_rtuple(
materials=materials
)
actuator = scad.make_assembly_rassembly(
assembly_id="integrated_50mm_bldc_joint_actuator",
name="50 mm 12-slot/14-pole BLDC joint actuator with 20:1 reducer and circular ESC",
)
for component_id, item, placement, name in component_specs:
actuator = scad.add_component_rassembly(
assembly=actuator,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
actuator = _add_public_connectors_rassembly(assembly=actuator)
actuator = _add_constraints_rassembly(assembly=actuator)
actuator = scad.solve_assembly_constraints_rassembly(assembly=actuator, strict=True)
ground_constraint_report(label="actuator", assembly=actuator)
return actuator
def make_integrated_bldc_joint_actuator_components_rtuple(
*, materials: dict[str, scad.Material]
) -> tuple[tuple[str, scad.Part | scad.Assembly, scad.Placement, str], ...]:
"""Build the actuator component inventory without creating a parent assembly."""
print(
f"ratio_plan: stage1={STAGE_1.fixed_ring_ratio:.1f}:1 "
f"stage2={STAGE_2.fixed_ring_ratio:.1f}:1 total={TOTAL_REDUCTION:.1f}:1"
)
reducer_housing = make_reducer_housing_rpart(material=materials["housing"])
motor_shell = make_motor_shell_rpart(material=materials["housing"])
rear_spider = make_rear_bearing_spider_rpart(material=materials["carrier"])
rear_cover = make_rear_electronics_cover_rpart(material=materials["housing"])
output_cap = make_output_bearing_cap_rpart(material=materials["carrier"])
stator = make_bldc_stator_rassembly(
steel_material=materials["electrical_steel"],
copper_material=materials["copper"],
)
rotor = make_bldc_rotor_rassembly(
steel_material=materials["gear"],
magnet_material=materials["magnet"],
)
controller = make_integrated_controller_rassembly(
pcb_material=materials["pcb"],
terminal_material=materials["terminal"],
)
stage1_ring = make_stage_ring_gear_rpart(stage=STAGE_1, material=materials["gear"])
stage1_planet = make_stage_planet_gear_rpart(stage=STAGE_1, material=materials["gear"])
stage1_carrier = make_stage1_carrier_sun_rpart(material=materials["gear"])
stage2_ring = make_stage_ring_gear_rpart(stage=STAGE_2, material=materials["gear"])
stage2_planet = make_stage_planet_gear_rpart(stage=STAGE_2, material=materials["gear"])
output_carrier = make_output_carrier_flange_rpart(stage=STAGE_2, material=materials["carrier"])
rear_motor_bearing = make_main_bearing_rassembly(
bearing_id="rear_motor_8x16x5",
spec=REAR_MOTOR_BEARING,
material=materials["gear"],
)
front_motor_bearing = make_main_bearing_rassembly(
bearing_id="front_motor_8x19x6",
spec=FRONT_MOTOR_BEARING,
material=materials["gear"],
)
interstage_bearing = make_main_bearing_rassembly(
bearing_id="interstage_5x10x3",
spec=INTERSTAGE_BEARING,
material=materials["gear"],
)
planet_bearing = make_standard_planet_bearing_rassembly(
bearing_id="planet_3x6x3",
spec=PLANET_BEARING,
material=materials["gear"],
)
output_bearing = make_main_bearing_rassembly(
bearing_id="output_16x24x5",
spec=OUTPUT_BEARING,
material=materials["gear"],
)
fixed_components = (
("reducer_housing", reducer_housing, scad.identity_placement_rplacement(), "Fixed reducer housing"),
("motor_shell", motor_shell, scad.identity_placement_rplacement(), "Fixed BLDC shell"),
("rear_bearing_spider", rear_spider, scad.identity_placement_rplacement(), "Rear motor-bearing spider"),
("rear_electronics_cover", rear_cover, scad.identity_placement_rplacement(), "Rear controller cover"),
("output_bearing_cap", output_cap, scad.identity_placement_rplacement(), "Output bearing cap"),
("stator", stator, scad.identity_placement_rplacement(), "12-slot fixed stator"),
("rotor", rotor, scad.identity_placement_rplacement(), "14-pole rotor and direct sun shaft"),
("controller", controller, scad.identity_placement_rplacement(), "Circular integrated controller"),
("stage1_carrier", stage1_carrier, scad.identity_placement_rplacement(), "Stage 1 carrier and stage 2 sun"),
("output_carrier", output_carrier, scad.identity_placement_rplacement(), "Stage 2 carrier and output flange"),
("stage1_ring", stage1_ring, _stage_rplacement(stage=STAGE_1), "Stage 1 fixed ring insert"),
("stage2_ring", stage2_ring, _stage_rplacement(stage=STAGE_2), "Stage 2 fixed ring insert"),
)
print(f"actuator_base_components: count={len(fixed_components)}")
planet_components = []
for stage, planet in ((STAGE_1, stage1_planet), (STAGE_2, stage2_planet)):
for index in range(PLANET_COUNT):
planet_components.append(
(
f"{stage.stage_id}_planet_{index + 1}",
planet,
make_planet_rplacement(stage=stage, index=index),
f"{stage.label} planet {index + 1}",
)
)
bearing_components = (
(
"rear_motor_bearing",
rear_motor_bearing,
make_coaxial_bearing_rplacement(center_z=REAR_BEARING_CENTER_Z),
"Rear rotor bearing",
),
(
"front_motor_bearing",
front_motor_bearing,
make_coaxial_bearing_rplacement(center_z=FRONT_MOTOR_BEARING_CENTER_Z),
"Front rotor bearing",
),
(
"interstage_bearing",
interstage_bearing,
make_coaxial_bearing_rplacement(center_z=INTERSTAGE_BEARING_CENTER_Z),
"Stage 1 carrier support bearing",
),
(
"output_bearing_1",
output_bearing,
make_coaxial_bearing_rplacement(center_z=OUTPUT_BEARING_1_CENTER_Z),
"Rear output bearing",
),
(
"output_bearing_2",
output_bearing,
make_coaxial_bearing_rplacement(center_z=OUTPUT_BEARING_2_CENTER_Z),
"Front output bearing",
),
)
planet_bearing_components = []
for stage in (STAGE_1, STAGE_2):
for index in range(PLANET_COUNT):
planet_bearing_components.append(
(
f"{stage.stage_id}_planet_bearing_{index + 1}",
planet_bearing,
make_planet_bearing_rplacement(stage=stage, index=index),
f"{stage.label} planet bearing {index + 1}",
)
)
print(f"bearing_components: motor=2 interstage=1 output=2 planet={PLANET_COUNT * 2}")
return tuple(
[
*fixed_components,
*planet_components,
*bearing_components,
*planet_bearing_components,
]
)
def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
forwarded = (
("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"),
("case_mount_axis", "output_bearing_cap", "case_mount_axis", "Fixed actuator case datum"),
("output_link_axis", "output_carrier", "output_link_axis", "Rotating six-hole output flange"),
("phase_terminal_access", "controller", "phase_access", "Rear phase-terminal service datum"),
("power_can_terminal_access", "controller", "power_can_access", "Rear power/CAN service datum"),
)
for connector_id, source_component_id, source_connector_id, name in forwarded:
assembly = scad.forward_connector_rassembly(
assembly=assembly,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
offset=None,
)
print("actuator_public_connectors: " + ",".join(item[0] for item in forwarded))
return assembly
def _add_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="reducer_housing")
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage1_ring")
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage2_ring")
fixed_pairs = (
("motor_shell_to_reducer_housing", "reducer_housing", "motor_mount_axis", "motor_shell", "reducer_mount_axis"),
("rear_spider_to_motor_shell", "motor_shell", "rear_spider_axis", "rear_bearing_spider", "shell_axis"),
("rear_cover_to_motor_shell", "motor_shell", "rear_cover_axis", "rear_electronics_cover", "shell_axis"),
("stator_to_motor_shell", "motor_shell", "stator_axis", "stator", "shell_axis"),
("controller_to_rear_cover", "rear_electronics_cover", "pcb_axis", "controller", "cover_axis"),
("stage1_ring_fixed", "reducer_housing", "stage1_ring_axis", "stage1_ring", "axis"),
("stage2_ring_fixed", "reducer_housing", "stage2_ring_axis", "stage2_ring", "axis"),
("output_cap_to_reducer_housing", "reducer_housing", "output_cap_axis", "output_bearing_cap", "housing_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in fixed_pairs:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
name=constraint_id.replace("_", " "),
)
primary_revolutes = (
("rotor_revolute", "reducer_housing", "front_motor_bearing_axis", "rotor", "front_bearing_axis"),
("stage1_carrier_revolute", "reducer_housing", "stage1_carrier_axis", "stage1_carrier", "carrier_axis"),
("output_carrier_revolute", "reducer_housing", "stage2_carrier_axis", "output_carrier", "carrier_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in primary_revolutes:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=0.0,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
assembly = _add_stage_constraints_rassembly(
assembly=assembly,
stage=STAGE_1,
sun_component="rotor",
sun_connector="front_bearing_axis",
ring_component="stage1_ring",
carrier_component="stage1_carrier",
)
assembly = _add_stage_constraints_rassembly(
assembly=assembly,
stage=STAGE_2,
sun_component="stage1_carrier",
sun_connector="carrier_axis",
ring_component="stage2_ring",
carrier_component="output_carrier",
)
assembly = _add_bearing_constraints_rassembly(assembly=assembly)
print("actuator_constraints: fixed=8 primary_revolute=3 planet_revolute=6 gear=6 internal=6 bearing_interfaces=22")
return assembly
def _add_stage_constraints_rassembly(
*,
assembly: scad.Assembly,
stage: StageSpec,
sun_component: str,
sun_connector: str,
ring_component: str,
carrier_component: str,
) -> scad.Assembly:
for index in range(PLANET_COUNT):
planet_component = f"{stage.stage_id}_planet_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{planet_component}_revolute",
connector_a=connector_ref(component_id=carrier_component, connector_id=f"planet_{index + 1}_axis"),
connector_b=connector_ref(component_id=planet_component, connector_id="axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{stage.label} planet {index + 1} bearing axis",
)
assembly = scad.add_gear_constraint_rassembly(
assembly=assembly,
constraint_id=f"{stage.stage_id}_sun_planet_{index + 1}_mesh",
connector_a=connector_ref(component_id=sun_component, connector_id=sun_connector),
connector_b=connector_ref(component_id=planet_component, connector_id="axis"),
pitch_radius_a=stage.sun_pitch_radius,
pitch_radius_b=stage.planet_pitch_radius,
phase_offset=None,
name=f"{stage.label} sun to planet {index + 1} external mesh",
)
assembly = scad.add_belt_constraint_rassembly(
assembly=assembly,
constraint_id=f"{stage.stage_id}_ring_planet_{index + 1}_internal_mesh",
connector_a=connector_ref(component_id=ring_component, connector_id="axis"),
connector_b=connector_ref(component_id=planet_component, connector_id="axis"),
pulley_radius_a=stage.ring_pitch_radius,
pulley_radius_b=stage.planet_pitch_radius,
phase_offset=None,
name=f"{stage.label} fixed-ring to planet {index + 1} internal mesh",
)
print(
f"{stage.stage_id}_mesh: sun_r={stage.sun_pitch_radius:.3f} "
f"planet_r={stage.planet_pitch_radius:.3f} center={stage.planet_center_radius:.3f}"
)
return assembly
def _add_bearing_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
interfaces = (
("rear_bearing_outer_to_spider", "rear_bearing_spider", "bearing_axis", "rear_motor_bearing", "outer_axis"),
("rear_bearing_inner_to_rotor", "rotor", "rear_bearing_axis", "rear_motor_bearing", "inner_axis"),
("front_bearing_outer_to_housing", "reducer_housing", "front_motor_bearing_axis", "front_motor_bearing", "outer_axis"),
("front_bearing_inner_to_rotor", "rotor", "front_bearing_axis", "front_motor_bearing", "inner_axis"),
("interstage_bearing_outer_to_housing", "reducer_housing", "interstage_bearing_axis", "interstage_bearing", "outer_axis"),
("interstage_bearing_inner_to_carrier", "stage1_carrier", "interstage_bearing_axis", "interstage_bearing", "inner_axis"),
("output_bearing_1_outer_to_cap", "output_bearing_cap", "bearing_1_axis", "output_bearing_1", "outer_axis"),
("output_bearing_1_inner_to_carrier", "output_carrier", "bearing_1_axis", "output_bearing_1", "inner_axis"),
("output_bearing_2_outer_to_cap", "output_bearing_cap", "bearing_2_axis", "output_bearing_2", "outer_axis"),
("output_bearing_2_inner_to_carrier", "output_carrier", "bearing_2_axis", "output_bearing_2", "inner_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in interfaces:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=None,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
for stage, carrier_component in ((STAGE_1, "stage1_carrier"), (STAGE_2, "output_carrier")):
for index in range(PLANET_COUNT):
planet = f"{stage.stage_id}_planet_{index + 1}"
bearing = f"{stage.stage_id}_planet_bearing_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{bearing}_outer_to_planet",
connector_a=connector_ref(component_id=planet, connector_id="bearing_axis"),
connector_b=connector_ref(component_id=bearing, connector_id="outer_axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{stage.label} planet {index + 1} bearing outer-ring fit",
)
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{bearing}_inner_to_pin",
connector_a=connector_ref(
component_id=carrier_component,
connector_id=f"planet_{index + 1}_bearing_axis",
),
connector_b=connector_ref(component_id=bearing, connector_id="inner_axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{stage.label} planet {index + 1} bearing inner-ring pin fit",
)
return assembly
def _stage_rplacement(*, stage: StageSpec) -> scad.Placement:
return scad.make_placement_rplacement(origin=(0.0, 0.0, stage.bottom_z))
@@ -0,0 +1,174 @@
"""Standard bearing factories and coaxial/planet placements."""
from __future__ import annotations
import simplecadapi as scad
if __package__:
from .common import make_annulus_rsolid, make_axis_part_rpart, make_z_rotation_rplacement
from .dimensions import BearingSpec, PLANET_COUNT, StageSpec
from .gears import planet_center_xy
else:
from common import make_annulus_rsolid, make_axis_part_rpart, make_z_rotation_rplacement
from dimensions import BearingSpec, PLANET_COUNT, StageSpec
from gears import planet_center_xy
def make_standard_planet_bearing_rassembly(
*,
bearing_id: str,
spec: BearingSpec,
material: scad.Material,
) -> scad.Assembly:
"""Create the reused catalog-style planet ball bearing assembly."""
bearing = make_main_bearing_rassembly(
bearing_id=bearing_id,
spec=spec,
material=material,
)
print(
f"bearing_{bearing_id}: bore={spec.bore_diameter:.1f} od={spec.outer_diameter:.1f} "
f"width={spec.width:.1f} balls={spec.ball_count} material={material.material_id}"
)
return bearing
def make_main_bearing_rassembly(
*,
bearing_id: str,
spec: BearingSpec,
material: scad.Material,
) -> scad.Assembly:
"""Create a uniquely identified bearing when several catalog sizes share one graph."""
bore_radius = spec.bore_diameter / 2.0
outer_radius = spec.outer_diameter / 2.0
ball_radius = spec.ball_diameter / 2.0
pitch_radius = (bore_radius + outer_radius) / 2.0
inner_outer_radius = pitch_radius - ball_radius * 0.55
outer_inner_radius = pitch_radius + ball_radius * 0.55
inner_ring = make_annulus_rsolid(
outer_radius=inner_outer_radius,
inner_radius=bore_radius,
bottom_z=-spec.width / 2.0,
height=spec.width,
tags=("role.bearing_inner_ring",),
)
outer_ring = make_annulus_rsolid(
outer_radius=outer_radius,
inner_radius=outer_inner_radius,
bottom_z=-spec.width / 2.0,
height=spec.width,
tags=("role.bearing_outer_ring",),
)
ball = scad.make_sphere_rsolid(
radius=ball_radius,
center=(pitch_radius, 0.0, 0.0),
)
inner_part = make_axis_part_rpart(
part_id=f"{bearing_id}_inner_ring",
body=inner_ring,
name=f"{bearing_id} inner race",
material=material,
connectors=(("axis", (0.0, 0.0, 0.0), "Inner-ring axis"),),
)
outer_part = make_axis_part_rpart(
part_id=f"{bearing_id}_outer_ring",
body=outer_ring,
name=f"{bearing_id} outer race",
material=material,
connectors=(("axis", (0.0, 0.0, 0.0), "Outer-ring axis"),),
)
ball_part = make_axis_part_rpart(
part_id=f"{bearing_id}_reusable_ball",
body=ball,
name=f"{bearing_id} reusable rolling element",
material=material,
connectors=(),
)
bearing = scad.make_assembly_rassembly(
assembly_id=bearing_id,
name=f"Ball bearing {spec.bore_diameter:g}x{spec.outer_diameter:g}x{spec.width:g}",
)
bearing = scad.add_component_rassembly(
assembly=bearing,
item=outer_part,
component_id="outer_ring",
placement=scad.identity_placement_rplacement(),
name="Outer ring",
)
bearing = scad.add_component_rassembly(
assembly=bearing,
item=inner_part,
component_id="inner_ring",
placement=scad.identity_placement_rplacement(),
name="Inner ring",
)
for index in range(spec.ball_count):
angle = 360.0 * index / spec.ball_count
bearing = scad.add_component_rassembly(
assembly=bearing,
item=ball_part,
component_id=f"ball_{index + 1:02d}",
placement=make_z_rotation_rplacement(origin=(0.0, 0.0, 0.0), angle_degrees=angle),
name=f"Rolling element {index + 1}",
)
bearing = scad.add_revolute_constraint_rassembly(
assembly=bearing,
constraint_id="inner_outer_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="outer_ring",
connector_id="axis",
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="inner_ring",
connector_id="axis",
),
drive_angle_degrees=None,
angle_limit=None,
name="Inner ring rotation in outer ring",
)
for connector_id, component_id in (("outer_axis", "outer_ring"), ("inner_axis", "inner_ring")):
bearing = scad.forward_connector_rassembly(
assembly=bearing,
connector_id=connector_id,
source_component_id=component_id,
source_connector_id="axis",
name=connector_id.replace("_", " "),
offset=None,
)
radial_wall = (spec.outer_diameter - spec.bore_diameter) / 2.0 - spec.ball_diameter
axial_margin = spec.width - spec.ball_diameter
print(
f"bearing_{bearing_id}: bore={spec.bore_diameter:.1f} od={spec.outer_diameter:.1f} "
f"width={spec.width:.1f} balls={spec.ball_count} radial_wall={radial_wall:.2f} "
f"axial_margin={axial_margin:.2f}"
)
return bearing
def make_coaxial_bearing_rplacement(*, center_z: float) -> scad.Placement:
"""Place a standard bearing center plane on the actuator Z axis."""
return make_z_rotation_rplacement(origin=(0.0, 0.0, center_z), angle_degrees=0.0)
def make_planet_bearing_rplacement(
*,
stage: StageSpec,
index: int,
) -> scad.Placement:
"""Place a standard planet bearing at the gear midplane."""
if index < 0 or index >= PLANET_COUNT:
raise ValueError(f"planet bearing index out of range: {index}")
center = planet_center_xy(stage=stage, index=index)
print(
f"{stage.stage_id}_planet_bearing_{index + 1}: "
f"center=({center[0]:.3f},{center[1]:.3f},{stage.mid_z:.3f})"
)
return make_z_rotation_rplacement(
origin=(center[0], center[1], stage.mid_z),
angle_degrees=0.0,
)
@@ -0,0 +1,164 @@
"""Shared construction, tagging, connector, and grounding helpers."""
from __future__ import annotations
import math
from collections.abc import Iterable
import simplecadapi as scad
from simplecadapi import ql
def apply_tags(*, shape: scad.Solid, tags: Iterable[str]) -> scad.Solid:
"""Apply semantic tags through the public functional API."""
tagged = shape
for tag in tags:
tagged = scad.apply_tag(shape=tagged, tag=tag)
return tagged
def make_annulus_rsolid(
*,
outer_radius: float,
inner_radius: float,
bottom_z: float,
height: float,
tags: Iterable[str],
) -> scad.Solid:
"""Create a strict single-solid annular cylinder."""
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
annulus = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
return apply_tags(shape=annulus, tags=tags)
def make_axis_part_rpart(
*,
part_id: str,
body: scad.Solid,
name: str,
material: scad.Material,
connectors: Iterable[tuple[str, tuple[float, float, float], str]],
) -> scad.Part:
"""Create a single-body part with stable placement-based axis datums."""
part = scad.make_part_rpart(part_id=part_id, body=body, name=name)
part = scad.assign_material_rpart(part=part, material=material)
connector_count = 0
for connector_id, origin, connector_name in connectors:
connector = scad.make_placement_connector_rconnector(
connector_id=connector_id,
placement=scad.make_placement_rplacement(origin=origin),
name=connector_name,
)
part = scad.add_connector_rpart(part=part, connector=connector)
connector_count += 1
ground_solid(label=part_id, solid=body)
print(f"part_{part_id}: connectors={connector_count} material={material.material_id}")
return part
def make_z_rotation_rplacement(
*,
origin: tuple[float, float, float],
angle_degrees: float,
) -> scad.Placement:
"""Return a right-handed placement rotated about Z."""
angle = math.radians(angle_degrees)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
)
def radial_centers(*, count: int, radius: float, angle_offset: float = 0.0):
"""Yield index, angle in degrees, and XY center on a bolt/pole circle."""
for index in range(count):
angle_degrees = angle_offset + 360.0 * index / count
angle = math.radians(angle_degrees)
yield index, angle_degrees, (radius * math.cos(angle), radius * math.sin(angle))
def make_axial_hole_cutters_rsolids(
*,
count: int,
pcd: float,
hole_radius: float,
bottom_z: float,
height: float,
angle_offset: float = 0.0,
) -> list[scad.Solid]:
"""Create equally spaced axial hole cutters."""
cutters = []
for _index, _angle, center in radial_centers(
count=count,
radius=pcd / 2.0,
angle_offset=angle_offset,
):
cutters.append(
scad.make_cylinder_rsolid(
radius=hole_radius,
height=height,
bottom_face_center=(center[0], center[1], bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
return cutters
def ground_solid(*, label: str, solid: scad.Solid) -> None:
"""Print a concise QL-backed solid summary."""
faces = ql.select(items=solid.get_faces()).all()
role_faces = ql.select(items=faces).where(ql.tag(pattern="role.*")).all()
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
def ground_compound(*, label: str, compound: scad.Compound) -> None:
"""Print a concise QL-backed assembly projection summary."""
solids = ql.select(items=compound.get_solids()).all()
faces = sum(len(ql.select(items=solid.get_faces()).all()) for solid in solids)
volume = sum(solid.get_volume() for solid in solids)
print(f"{label}: solids={len(solids)} faces={faces} volume={volume:.3f}")
def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
"""Create a component-scoped connector reference."""
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
def ground_constraint_report(*, label: str, assembly: scad.Assembly) -> None:
"""Print solved state and only non-zero residual facts."""
report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly)
worst_translation = max((item.translation_error for item in report.residuals), default=0.0)
worst_angle = max((item.angular_error_degrees for item in report.residuals), default=0.0)
print(
f"{label}_constraints: solved={report.solved} components={len(assembly.component_ids())} "
f"constraints={len(assembly.constraint_ids())} unsolved={len(report.unsolved_component_ids)} "
f"max_translation={worst_translation:.6g} max_angle={worst_angle:.6g}"
)
@@ -0,0 +1,235 @@
"""Design constants for the integrated 50 mm BLDC joint actuator."""
from __future__ import annotations
from dataclasses import dataclass
PACKAGE_RADIUS = 25.0
PACKAGE_STRUCTURAL_BOTTOM_Z = -37.0
PACKAGE_TOP_Z = 40.3
MOTOR_SLOT_COUNT = 12
MOTOR_POLE_COUNT = 14
MOTOR_SHELL_INNER_RADIUS = 23.20
MOTOR_SHELL_BOTTOM_Z = -34.0
MOTOR_SHELL_TOP_Z = -6.0
MOTOR_STATOR_BOTTOM_Z = -24.5
MOTOR_STATOR_TOP_Z = -8.5
MOTOR_STATOR_OUTER_RADIUS = 23.20
MOTOR_STATOR_YOKE_INNER_RADIUS = 20.20
MOTOR_STATOR_TOOTH_INNER_RADIUS = 15.00
MOTOR_STATOR_TOOTH_WIDTH = 2.60
MOTOR_ROTOR_BOTTOM_Z = -25.0
MOTOR_ROTOR_TOP_Z = -7.5
MOTOR_ROTOR_BACKIRON_RADIUS = 13.50
MOTOR_MAGNET_OUTER_RADIUS = 14.70
MOTOR_MAGNET_TANGENTIAL_WIDTH = 5.0
MOTOR_SHAFT_RADIUS = 4.0
MOTOR_AIR_GAP = MOTOR_STATOR_TOOTH_INNER_RADIUS - MOTOR_MAGNET_OUTER_RADIUS
REAR_COVER_BOTTOM_Z = -37.0
REAR_COVER_THICKNESS = 3.0
PCB_BOTTOM_Z = -33.6
PCB_THICKNESS = 1.6
PCB_RADIUS = 22.2
PCB_CENTER_BORE_RADIUS = 5.0
PCB_STANDOFF_PCD = 33.0
PCB_MOUNT_HOLE_RADIUS = 1.10
REAR_COLUMN_PCD = 40.6
REAR_COLUMN_RADIUS = 3.2
REAR_SPIDER_BOSS_RADIUS = 2.9
REAR_FASTENER_HOLE_RADIUS = 1.35
REAR_BEARING_CENTER_Z = -29.0
FRONT_MOTOR_BEARING_CENTER_Z = -2.5
REDUCER_HOUSING_BOTTOM_Z = -6.0
REDUCER_HOUSING_FRONT_Z = 24.3
REDUCER_HOUSING_INNER_RADIUS = 22.80
RING_INSERT_OUTER_RADIUS = 22.82
MOTOR_INTERFACE_PCD = 43.0
OUTPUT_CAP_INTERFACE_PCD = 43.0
M3_CLEARANCE_RADIUS = 1.60
HOUSING_INTERFACE_LAND_INNER_RADIUS = 18.50
PLANET_COUNT = 3
PRESSURE_ANGLE = 20.0
HELIX_ANGLE = 24.0
BACKLASH = 0.03
ADDENDUM_FACTOR = 1.0
CLEARANCE_FACTOR = 0.25
RING_RIM_THICKNESS = 1.80
RING_SUPPORT_OVERLAP = 0.30
GEAR_HEIGHT = 5.50
STAGE1_CARRIER_BOTTOM_Z = 8.20
STAGE1_CARRIER_THICKNESS = 2.50
STAGE1_PIN_RADIUS = 1.48
STAGE1_PIN_BOTTOM_Z = 2.25
STAGE1_HUB_RADIUS = 4.2
STAGE1_PAD_RADIUS = 4.0
STAGE1_ARM_WIDTH = 3.2
INTERSTAGE_SHAFT_RADIUS = 2.50
STAGE2_CARRIER_BOTTOM_Z = 20.20
STAGE2_CARRIER_THICKNESS = 3.0
STAGE2_PIN_RADIUS = 1.48
STAGE2_PIN_BOTTOM_Z = 14.25
STAGE2_HUB_RADIUS = 8.8
STAGE2_PAD_RADIUS = 4.1
STAGE2_ARM_WIDTH = 3.5
OUTPUT_SHAFT_RADIUS = 7.98
OUTPUT_CAP_BOTTOM_Z = 24.3
OUTPUT_CAP_CARTRIDGE_TOP_Z = 34.8
OUTPUT_CAP_TOP_Z = 36.3
OUTPUT_BEARING_1_CENTER_Z = 26.8
OUTPUT_BEARING_2_CENTER_Z = 31.8
OUTPUT_FLANGE_BOTTOM_Z = 35.3
OUTPUT_FLANGE_TOP_Z = 38.8
OUTPUT_FLANGE_RADIUS = 22.4
OUTPUT_LINK_HOLE_PCD = 34.0
OUTPUT_LINK_BOLT_COUNT = 6
OUTPUT_LINK_BOLT_ANGLES_DEGREES = (30.0, 90.0, 150.0, 210.0, 270.0, 330.0)
OUTPUT_LINK_TAP_RADIUS = 1.25
OUTPUT_LINK_THREAD_DEPTH = 3.0
OUTPUT_REGISTER_RADIUS = OUTPUT_SHAFT_RADIUS
OUTPUT_REGISTER_HEIGHT = PACKAGE_TOP_Z - OUTPUT_FLANGE_TOP_Z
OUTPUT_CASE_CLAMP_CENTER_Z = 20.0
@dataclass(frozen=True)
class StageSpec:
"""One fixed-ring planetary stage."""
stage_id: str
label: str
module: float
sun_teeth: int
planet_teeth: int
bottom_z: float
@property
def ring_teeth(self) -> int:
return self.sun_teeth + 2 * self.planet_teeth
@property
def top_z(self) -> float:
return self.bottom_z + GEAR_HEIGHT
@property
def mid_z(self) -> float:
return self.bottom_z + GEAR_HEIGHT / 2.0
@property
def sun_pitch_radius(self) -> float:
return self.module * self.sun_teeth / 2.0
@property
def planet_pitch_radius(self) -> float:
return self.module * self.planet_teeth / 2.0
@property
def ring_pitch_radius(self) -> float:
return self.module * self.ring_teeth / 2.0
@property
def planet_center_radius(self) -> float:
return self.sun_pitch_radius + self.planet_pitch_radius
@property
def fixed_ring_ratio(self) -> float:
return 1.0 + self.ring_teeth / self.sun_teeth
@property
def ring_outer_radius(self) -> float:
return (
self.ring_pitch_radius
+ self.module * (ADDENDUM_FACTOR + CLEARANCE_FACTOR)
+ RING_RIM_THICKNESS
)
@dataclass(frozen=True)
class BearingSpec:
"""Catalog-style radial ball bearing dimensions."""
bore_diameter: float
outer_diameter: float
width: float
ball_diameter: float
ball_count: int
STAGE_1 = StageSpec(
stage_id="stage1",
label="Stage 1",
module=0.80,
sun_teeth=15,
planet_teeth=15,
bottom_z=2.0,
)
STAGE_2 = StageSpec(
stage_id="stage2",
label="Stage 2",
module=0.55,
sun_teeth=18,
planet_teeth=27,
bottom_z=14.0,
)
REAR_MOTOR_BEARING = BearingSpec(8.0, 16.0, 5.0, 2.0, 8)
FRONT_MOTOR_BEARING = BearingSpec(8.0, 19.0, 6.0, 2.4, 9)
INTERSTAGE_BEARING = BearingSpec(5.0, 10.0, 3.0, 1.0, 8)
PLANET_BEARING = BearingSpec(3.0, 6.0, 3.0, 0.7, 8)
OUTPUT_BEARING = BearingSpec(16.0, 24.0, 5.0, 2.5, 12)
REAR_SPIDER_BOTTOM_Z = REAR_BEARING_CENTER_Z - REAR_MOTOR_BEARING.width / 2.0
INTERSTAGE_BEARING_CENTER_Z = (
STAGE1_CARRIER_BOTTOM_Z + STAGE1_CARRIER_THICKNESS + STAGE_2.bottom_z
) / 2.0
TOTAL_REDUCTION = STAGE_1.fixed_ring_ratio * STAGE_2.fixed_ring_ratio
def validate_design_dimensions() -> None:
"""Fail early if a packaging or minimum-ligament invariant is broken."""
assert MOTOR_AIR_GAP >= 0.30
assert MOTOR_STATOR_OUTER_RADIUS == MOTOR_SHELL_INNER_RADIUS
assert abs(PACKAGE_RADIUS - MOTOR_SHELL_INNER_RADIUS - 1.80) < 1.0e-9
assert abs(PACKAGE_RADIUS - REDUCER_HOUSING_INNER_RADIUS - 2.20) < 1.0e-9
assert max(STAGE_1.ring_outer_radius, STAGE_2.ring_outer_radius) < RING_INSERT_OUTER_RADIUS
assert RING_INSERT_OUTER_RADIUS > REDUCER_HOUSING_INNER_RADIUS
assert (
MOTOR_INTERFACE_PCD / 2.0
- M3_CLEARANCE_RADIUS
- HOUSING_INTERFACE_LAND_INNER_RADIUS
>= 1.40 - 1.0e-9
)
assert REAR_SPIDER_BOSS_RADIUS - REAR_FASTENER_HOLE_RADIUS >= 1.50
assert REAR_COLUMN_RADIUS > REAR_SPIDER_BOSS_RADIUS
assert REAR_SPIDER_BOTTOM_Z > MOTOR_SHELL_BOTTOM_Z
assert OUTPUT_FLANGE_RADIUS <= PACKAGE_RADIUS
assert OUTPUT_LINK_HOLE_PCD / 2.0 + OUTPUT_LINK_TAP_RADIUS < OUTPUT_FLANGE_RADIUS
assert OUTPUT_LINK_THREAD_DEPTH < OUTPUT_FLANGE_TOP_Z - OUTPUT_FLANGE_BOTTOM_Z
assert OUTPUT_REGISTER_HEIGHT >= 1.5
assert OUTPUT_REGISTER_RADIUS < OUTPUT_LINK_HOLE_PCD / 2.0 - OUTPUT_LINK_TAP_RADIUS
assert REDUCER_HOUSING_BOTTOM_Z < OUTPUT_CASE_CLAMP_CENTER_Z < REDUCER_HOUSING_FRONT_Z
assert OUTPUT_BEARING_2_CENTER_Z - OUTPUT_BEARING_1_CENTER_Z == OUTPUT_BEARING.width
assert abs(TOTAL_REDUCTION - 20.0) < 1.0e-9
assert (STAGE_1.sun_teeth + STAGE_1.ring_teeth) % PLANET_COUNT == 0
assert (STAGE_2.sun_teeth + STAGE_2.ring_teeth) % PLANET_COUNT == 0
assert (
INTERSTAGE_BEARING_CENTER_Z - INTERSTAGE_BEARING.width / 2.0
> STAGE1_CARRIER_BOTTOM_Z + STAGE1_CARRIER_THICKNESS
)
assert (
INTERSTAGE_BEARING_CENTER_Z + INTERSTAGE_BEARING.width / 2.0
< STAGE_2.bottom_z
)
print(
"design_dimensions: "
f"diameter={PACKAGE_RADIUS * 2.0:.1f} structural_length="
f"{PACKAGE_TOP_Z - PACKAGE_STRUCTURAL_BOTTOM_Z:.1f} air_gap={MOTOR_AIR_GAP:.2f} "
f"ratio={TOTAL_REDUCTION:.1f}"
)
@@ -0,0 +1,270 @@
"""Circular integrated controller PCB, power stages, and rear terminals."""
from __future__ import annotations
import math
import simplecadapi as scad
try:
from .common import (
apply_tags,
connector_ref,
ground_constraint_report,
make_axial_hole_cutters_rsolids,
make_axis_part_rpart,
radial_centers,
)
from .dimensions import (
PCB_BOTTOM_Z,
PCB_CENTER_BORE_RADIUS,
PCB_MOUNT_HOLE_RADIUS,
PCB_RADIUS,
PCB_STANDOFF_PCD,
PCB_THICKNESS,
REAR_COLUMN_PCD,
)
except ImportError: # Support direct execution from this example directory.
from common import (
apply_tags,
connector_ref,
ground_constraint_report,
make_axial_hole_cutters_rsolids,
make_axis_part_rpart,
radial_centers,
)
from dimensions import (
PCB_BOTTOM_Z,
PCB_CENTER_BORE_RADIUS,
PCB_MOUNT_HOLE_RADIUS,
PCB_RADIUS,
PCB_STANDOFF_PCD,
PCB_THICKNESS,
REAR_COLUMN_PCD,
)
PHASE_TERMINAL_CENTER = (-11.0, 0.0)
POWER_CAN_TERMINAL_CENTER = (11.0, 0.0)
MOSFET_ANGLES = (22.5, 67.5, 112.5, 202.5, 247.5, 292.5)
def make_integrated_controller_rassembly(
*,
pcb_material: scad.Material,
terminal_material: scad.Material,
) -> scad.Assembly:
"""Build the circular ESC with six power devices and two service terminals."""
pcb = _make_controller_pcb_rpart(material=pcb_material)
mosfet = _make_mosfet_package_rpart(material=terminal_material)
phase_terminal = _make_terminal_block_rpart(
part_id="three_phase_terminal",
name="Three-position motor phase terminal",
width=8.0,
pin_count=3,
material=terminal_material,
)
power_terminal = _make_terminal_block_rpart(
part_id="power_can_terminal",
name="Four-position DC power and CAN terminal",
width=8.0,
pin_count=4,
material=terminal_material,
)
controller = scad.make_assembly_rassembly(
assembly_id="integrated_circular_motor_controller",
name="44.4 mm circular integrated BLDC controller",
)
controller = scad.add_component_rassembly(
assembly=controller,
item=pcb,
component_id="pcb",
placement=scad.identity_placement_rplacement(),
name="Circular controller PCB",
)
controller = scad.ground_component_rassembly(assembly=controller, component_id="pcb")
for index, angle in enumerate(MOSFET_ANGLES):
radians = math.radians(angle)
center = (13.2 * math.cos(radians), 13.2 * math.sin(radians))
component_id = f"mosfet_{index + 1}"
z = PCB_BOTTOM_Z + PCB_THICKNESS
controller = scad.add_component_rassembly(
assembly=controller,
item=mosfet,
component_id=component_id,
placement=scad.make_placement_rplacement(origin=(center[0], center[1], z)),
name=f"Power MOSFET package {index + 1}",
)
controller = scad.add_fixed_constraint_rassembly(
assembly=controller,
constraint_id=f"{component_id}_soldered",
connector_a=connector_ref(component_id="pcb", connector_id=component_id),
connector_b=connector_ref(component_id=component_id, connector_id="solder_axis"),
name=f"MOSFET {index + 1} solder attachment",
)
for component_id, item, center, connector_id in (
("phase_terminal", phase_terminal, PHASE_TERMINAL_CENTER, "phase_terminal"),
("power_can_terminal", power_terminal, POWER_CAN_TERMINAL_CENTER, "power_can_terminal"),
):
controller = scad.add_component_rassembly(
assembly=controller,
item=item,
component_id=component_id,
placement=scad.make_placement_rplacement(origin=(center[0], center[1], -38.5)),
name=item.name,
)
controller = scad.add_fixed_constraint_rassembly(
assembly=controller,
constraint_id=f"{component_id}_soldered",
connector_a=connector_ref(component_id="pcb", connector_id=connector_id),
connector_b=connector_ref(component_id=component_id, connector_id="solder_axis"),
name=f"{component_id.replace('_', ' ')} solder and screw retention",
)
for connector_id in ("cover_axis", "phase_access", "power_can_access"):
controller = scad.forward_connector_rassembly(
assembly=controller,
connector_id=connector_id,
source_component_id="pcb",
source_connector_id=connector_id,
name=connector_id.replace("_", " "),
offset=None,
)
controller = scad.solve_assembly_constraints_rassembly(assembly=controller, strict=True)
ground_constraint_report(label="controller", assembly=controller)
print("controller_packaging: pcb_d=44.4 mosfets=6 phase_pins=3 power_can_pins=4")
return controller
def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
board = scad.make_cylinder_rsolid(
radius=PCB_RADIUS,
height=PCB_THICKNESS,
bottom_face_center=(0.0, 0.0, PCB_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
cutters: list[scad.Solid] = [
scad.make_cylinder_rsolid(
radius=PCB_CENTER_BORE_RADIUS,
height=PCB_THICKNESS + 2.0,
bottom_face_center=(0.0, 0.0, PCB_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
]
cutters.extend(
make_axial_hole_cutters_rsolids(
count=4,
pcd=PCB_STANDOFF_PCD,
hole_radius=PCB_MOUNT_HOLE_RADIUS,
bottom_z=PCB_BOTTOM_Z - 1.0,
height=PCB_THICKNESS + 2.0,
angle_offset=45.0,
)
)
cutters.extend(
make_axial_hole_cutters_rsolids(
count=4,
pcd=REAR_COLUMN_PCD,
hole_radius=3.45,
bottom_z=PCB_BOTTOM_Z - 1.0,
height=PCB_THICKNESS + 2.0,
)
)
for x, count in ((PHASE_TERMINAL_CENTER[0], 3), (POWER_CAN_TERMINAL_CENTER[0], 4)):
for pin in range(count):
y = (pin - (count - 1) / 2.0) * 1.8
cutters.append(
scad.make_cylinder_rsolid(
radius=0.65,
height=PCB_THICKNESS + 2.0,
bottom_face_center=(x, y, PCB_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
)
board = scad.cut_rsolid(board, cutters, skip_non_intersecting=False)
board = apply_tags(
shape=board,
tags=("role.circular_esc_pcb", "role.controller_mounting_holes", "group.integrated_electronics"),
)
connectors = [
("cover_axis", (0.0, 0.0, PCB_BOTTOM_Z + PCB_THICKNESS / 2.0), "Rear-cover PCB plane"),
("phase_terminal", (*PHASE_TERMINAL_CENTER, PCB_BOTTOM_Z), "Phase terminal solder datum"),
("power_can_terminal", (*POWER_CAN_TERMINAL_CENTER, PCB_BOTTOM_Z), "Power/CAN terminal solder datum"),
("phase_access", (*PHASE_TERMINAL_CENTER, -37.0), "Phase terminal service axis"),
("power_can_access", (*POWER_CAN_TERMINAL_CENTER, -37.0), "Power/CAN service axis"),
]
for index, angle in enumerate(MOSFET_ANGLES):
radians = math.radians(angle)
center = (13.2 * math.cos(radians), 13.2 * math.sin(radians))
connectors.append(
(
f"mosfet_{index + 1}",
(center[0], center[1], PCB_BOTTOM_Z + PCB_THICKNESS),
f"MOSFET {index + 1} solder datum",
)
)
print("pcb_holes: center=10.0 mount=4 column_notches=4 terminal_pins=7")
return make_axis_part_rpart(
part_id="circular_controller_pcb",
body=board,
name="44.4 mm circular ESC PCB with service cutouts",
material=material,
connectors=connectors,
)
def _make_mosfet_package_rpart(*, material: scad.Material) -> scad.Part:
package = scad.make_box_rsolid(
width=4.0,
height=3.0,
depth=1.4,
bottom_face_center=(0.0, 0.0, 0.0),
)
package = apply_tags(shape=package, tags=("role.power_mosfet", "group.three_phase_bridge"))
return make_axis_part_rpart(
part_id="reusable_power_mosfet",
body=package,
name="Reusable power MOSFET package",
material=material,
connectors=(("solder_axis", (0.0, 0.0, 0.0), "PCB solder plane"),),
)
def _make_terminal_block_rpart(
*,
part_id: str,
name: str,
width: float,
pin_count: int,
material: scad.Material,
) -> scad.Part:
body = scad.make_box_rsolid(
width=width,
height=6.0,
depth=4.9,
bottom_face_center=(0.0, 0.0, 0.0),
)
access_cutters = []
for pin in range(pin_count):
y = (pin - (pin_count - 1) / 2.0) * 1.8
access_cutters.append(
scad.make_cylinder_rsolid(
radius=0.75,
height=width + 2.0,
bottom_face_center=(-width / 2.0 - 1.0, y, 2.45),
axis=(1.0, 0.0, 0.0),
)
)
body = scad.cut_rsolid(body, access_cutters, skip_non_intersecting=False)
body = apply_tags(shape=body, tags=("role.rear_wiring_terminal", "role.service_access"))
print(f"terminal_{part_id}: pins={pin_count} access_holes={pin_count} width={width:.1f}")
return make_axis_part_rpart(
part_id=part_id,
body=body,
name=name,
material=material,
connectors=(("solder_axis", (0.0, 0.0, 4.9), "PCB solder and screw datum"),),
)
@@ -0,0 +1,441 @@
"""Herringbone ring, planet, carrier, and output parts for the 20:1 reducer."""
from __future__ import annotations
import math
import simplecadapi as scad
try:
from .common import (
apply_tags,
make_axis_part_rpart,
make_axial_hole_cutters_rsolids,
make_z_rotation_rplacement,
)
from .dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CLEARANCE_FACTOR,
GEAR_HEIGHT,
HELIX_ANGLE,
INTERSTAGE_BEARING_CENTER_Z,
INTERSTAGE_SHAFT_RADIUS,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
OUTPUT_FLANGE_BOTTOM_Z,
OUTPUT_FLANGE_RADIUS,
OUTPUT_FLANGE_TOP_Z,
OUTPUT_LINK_BOLT_ANGLES_DEGREES,
OUTPUT_LINK_BOLT_COUNT,
OUTPUT_LINK_HOLE_PCD,
OUTPUT_LINK_TAP_RADIUS,
OUTPUT_LINK_THREAD_DEPTH,
OUTPUT_REGISTER_HEIGHT,
OUTPUT_SHAFT_RADIUS,
PLANET_BEARING,
PLANET_COUNT,
PRESSURE_ANGLE,
RING_INSERT_OUTER_RADIUS,
RING_RIM_THICKNESS,
RING_SUPPORT_OVERLAP,
STAGE1_ARM_WIDTH,
STAGE1_CARRIER_BOTTOM_Z,
STAGE1_CARRIER_THICKNESS,
STAGE1_HUB_RADIUS,
STAGE1_PAD_RADIUS,
STAGE1_PIN_BOTTOM_Z,
STAGE1_PIN_RADIUS,
STAGE2_ARM_WIDTH,
STAGE2_CARRIER_BOTTOM_Z,
STAGE2_CARRIER_THICKNESS,
STAGE2_HUB_RADIUS,
STAGE2_PAD_RADIUS,
STAGE2_PIN_BOTTOM_Z,
STAGE2_PIN_RADIUS,
STAGE_1,
STAGE_2,
StageSpec,
)
except ImportError: # Support direct execution from this example directory.
from common import (
apply_tags,
make_axis_part_rpart,
make_axial_hole_cutters_rsolids,
make_z_rotation_rplacement,
)
from dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CLEARANCE_FACTOR,
GEAR_HEIGHT,
HELIX_ANGLE,
INTERSTAGE_BEARING_CENTER_Z,
INTERSTAGE_SHAFT_RADIUS,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
OUTPUT_FLANGE_BOTTOM_Z,
OUTPUT_FLANGE_RADIUS,
OUTPUT_FLANGE_TOP_Z,
OUTPUT_LINK_BOLT_ANGLES_DEGREES,
OUTPUT_LINK_BOLT_COUNT,
OUTPUT_LINK_HOLE_PCD,
OUTPUT_LINK_TAP_RADIUS,
OUTPUT_LINK_THREAD_DEPTH,
OUTPUT_REGISTER_HEIGHT,
OUTPUT_SHAFT_RADIUS,
PLANET_BEARING,
PLANET_COUNT,
PRESSURE_ANGLE,
RING_INSERT_OUTER_RADIUS,
RING_RIM_THICKNESS,
RING_SUPPORT_OVERLAP,
STAGE1_ARM_WIDTH,
STAGE1_CARRIER_BOTTOM_Z,
STAGE1_CARRIER_THICKNESS,
STAGE1_HUB_RADIUS,
STAGE1_PAD_RADIUS,
STAGE1_PIN_BOTTOM_Z,
STAGE1_PIN_RADIUS,
STAGE2_ARM_WIDTH,
STAGE2_CARRIER_BOTTOM_Z,
STAGE2_CARRIER_THICKNESS,
STAGE2_HUB_RADIUS,
STAGE2_PAD_RADIUS,
STAGE2_PIN_BOTTOM_Z,
STAGE2_PIN_RADIUS,
STAGE_1,
STAGE_2,
StageSpec,
)
def make_stage_ring_gear_rpart(
*,
stage: StageSpec,
material: scad.Material,
) -> scad.Part:
"""Create one herringbone ring insert with a full housing support rim."""
ring = scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=stage.ring_teeth,
module=stage.module,
pressure_angle=PRESSURE_ANGLE,
helix_angle=-HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
rim_thickness=RING_RIM_THICKNESS,
backlash=BACKLASH,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
)
support = scad.make_cylinder_rsolid(
radius=RING_INSERT_OUTER_RADIUS,
height=GEAR_HEIGHT,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
support_bore = scad.make_cylinder_rsolid(
radius=stage.ring_outer_radius - RING_SUPPORT_OVERLAP,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
support = scad.cut_rsolid(support, support_bore, skip_non_intersecting=False)
ring = scad.union_rsolid(ring, support, glue=False)
ring = apply_tags(
shape=ring,
tags=(f"role.{stage.stage_id}.fixed_ring_gear", "role.ring_gear_press_fit", "group.two_stage_reducer"),
)
print(
f"{stage.stage_id}_ring: teeth={stage.ring_teeth} pitch_r={stage.ring_pitch_radius:.3f} "
f"toothed_outer_r={stage.ring_outer_radius:.3f} insert_d={RING_INSERT_OUTER_RADIUS * 2.0:.2f}"
)
return make_axis_part_rpart(
part_id=f"{stage.stage_id}_fixed_ring",
body=ring,
name=f"{stage.label} replaceable fixed herringbone ring insert",
material=material,
connectors=(("axis", (0.0, 0.0, GEAR_HEIGHT / 2.0), "Fixed ring axis"),),
)
def make_stage_planet_gear_rpart(
*,
stage: StageSpec,
material: scad.Material,
) -> scad.Part:
"""Create one reusable herringbone planet with a standard-bearing seat."""
planet = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=stage.planet_teeth,
module=stage.module,
pressure_angle=PRESSURE_ANGLE,
helix_angle=-HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
bearing_seat_radius = PLANET_BEARING.outer_diameter / 2.0 + 0.05
bearing_seat = scad.make_cylinder_rsolid(
radius=bearing_seat_radius,
height=GEAR_HEIGHT + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
planet = scad.cut_rsolid(planet, bearing_seat, skip_non_intersecting=False)
planet = apply_tags(
shape=planet,
tags=(f"role.{stage.stage_id}.planet_gear", "role.planet_bearing_seat", "group.two_stage_reducer"),
)
root_radius = stage.planet_pitch_radius - stage.module * (ADDENDUM_FACTOR + CLEARANCE_FACTOR)
print(
f"{stage.stage_id}_planet: teeth={stage.planet_teeth} pitch_r={stage.planet_pitch_radius:.3f} "
f"bearing_seat_r={bearing_seat_radius:.3f} root_ligament={root_radius - bearing_seat_radius:.3f}"
)
return make_axis_part_rpart(
part_id=f"{stage.stage_id}_reusable_planet",
body=planet,
name=f"{stage.label} reusable bearing-supported planet",
material=material,
connectors=(
("axis", (0.0, 0.0, GEAR_HEIGHT / 2.0), "Planet spin axis"),
("bearing_axis", (0.0, 0.0, GEAR_HEIGHT / 2.0), "Planet bearing outer-ring axis"),
),
)
def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part:
"""Create the first carrier and integral second-stage sun/shaft."""
carrier = _make_carrier_body_rsolid(
stage=STAGE_1,
plate_bottom_z=STAGE1_CARRIER_BOTTOM_Z,
plate_thickness=STAGE1_CARRIER_THICKNESS,
pin_bottom_z=STAGE1_PIN_BOTTOM_Z,
pin_radius=STAGE1_PIN_RADIUS,
hub_radius=STAGE1_HUB_RADIUS,
arm_width=STAGE1_ARM_WIDTH,
pad_radius=STAGE1_PAD_RADIUS,
)
shaft = scad.make_cylinder_rsolid(
radius=INTERSTAGE_SHAFT_RADIUS,
height=STAGE_2.top_z - STAGE1_CARRIER_BOTTOM_Z + 0.1,
bottom_face_center=(0.0, 0.0, STAGE1_CARRIER_BOTTOM_Z - 0.05),
axis=(0.0, 0.0, 1.0),
)
stage2_sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=STAGE_2.sun_teeth,
module=STAGE_2.module,
pressure_angle=PRESSURE_ANGLE,
helix_angle=HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
stage2_sun = scad.translate_shape(shape=stage2_sun, vector=(0.0, 0.0, STAGE_2.bottom_z))
carrier = scad.union_rsolid(carrier, shaft, stage2_sun, glue=False)
carrier = apply_tags(
shape=carrier,
tags=("role.stage1.planet_carrier", "role.stage2.sun_gear", "role.integral_interstage_drive", "group.two_stage_reducer"),
)
connectors = [
("carrier_axis", (0.0, 0.0, INTERSTAGE_BEARING_CENTER_Z), "Stage 1 carrier bearing axis"),
(
"interstage_bearing_axis",
(0.0, 0.0, INTERSTAGE_BEARING_CENTER_Z),
"Interstage bearing inner-ring seat",
),
("stage2_sun_axis", (0.0, 0.0, STAGE_2.mid_z), "Integral stage 2 sun axis"),
]
for index in range(PLANET_COUNT):
center = planet_center_xy(stage=STAGE_1, index=index)
connectors.extend(
(
(f"planet_{index + 1}_axis", (*center, STAGE_1.mid_z), f"Stage 1 planet {index + 1} axis"),
(f"planet_{index + 1}_bearing_axis", (*center, STAGE_1.mid_z), f"Stage 1 planet {index + 1} bearing pin"),
)
)
print(
f"stage1_carrier_sun: pins={PLANET_COUNT} shaft_d={INTERSTAGE_SHAFT_RADIUS * 2.0:.2f} "
f"stage2_sun_teeth={STAGE_2.sun_teeth}"
)
return make_axis_part_rpart(
part_id="stage1_carrier_integral_stage2_sun",
body=carrier,
name="Stage 1 carrier with integral stage-2 sun shaft",
material=material,
connectors=connectors,
)
def make_output_carrier_flange_rpart(
*,
stage: StageSpec,
material: scad.Material,
) -> scad.Part:
"""Create the second carrier, 16 mm bearing land, and output flange."""
carrier = _make_carrier_body_rsolid(
stage=stage,
plate_bottom_z=STAGE2_CARRIER_BOTTOM_Z,
plate_thickness=STAGE2_CARRIER_THICKNESS,
pin_bottom_z=STAGE2_PIN_BOTTOM_Z,
pin_radius=STAGE2_PIN_RADIUS,
hub_radius=STAGE2_HUB_RADIUS,
arm_width=STAGE2_ARM_WIDTH,
pad_radius=STAGE2_PAD_RADIUS,
)
shaft = scad.make_cylinder_rsolid(
radius=OUTPUT_SHAFT_RADIUS,
height=(
OUTPUT_FLANGE_TOP_Z
+ OUTPUT_REGISTER_HEIGHT
- STAGE2_CARRIER_BOTTOM_Z
+ 0.05
),
bottom_face_center=(0.0, 0.0, STAGE2_CARRIER_BOTTOM_Z - 0.05),
axis=(0.0, 0.0, 1.0),
)
flange = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_RADIUS,
height=OUTPUT_FLANGE_TOP_Z - OUTPUT_FLANGE_BOTTOM_Z,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
output = scad.union_rsolid(carrier, shaft, flange, glue=False)
output = scad.cut_rsolid(
output,
make_axial_hole_cutters_rsolids(
count=OUTPUT_LINK_BOLT_COUNT,
pcd=OUTPUT_LINK_HOLE_PCD,
hole_radius=OUTPUT_LINK_TAP_RADIUS,
bottom_z=OUTPUT_FLANGE_TOP_Z - OUTPUT_LINK_THREAD_DEPTH,
height=OUTPUT_LINK_THREAD_DEPTH + 1.0,
angle_offset=OUTPUT_LINK_BOLT_ANGLES_DEGREES[0],
),
skip_non_intersecting=False,
)
output = apply_tags(
shape=output,
tags=("role.stage2.output_carrier", "role.output_bearing_land", "role.output_link_flange", "group.two_stage_reducer"),
)
connectors = [
("carrier_axis", (0.0, 0.0, STAGE2_CARRIER_BOTTOM_Z + STAGE2_CARRIER_THICKNESS / 2.0), "Stage 2 output carrier axis"),
("bearing_1_axis", (0.0, 0.0, OUTPUT_BEARING_1_CENTER_Z), "Rear output bearing inner-ring seat"),
("bearing_2_axis", (0.0, 0.0, OUTPUT_BEARING_2_CENTER_Z), "Front output bearing inner-ring seat"),
("output_link_axis", (0.0, 0.0, OUTPUT_FLANGE_TOP_Z), "Six-hole driven-link flange"),
]
for index in range(PLANET_COUNT):
center = planet_center_xy(stage=stage, index=index)
connectors.extend(
(
(f"planet_{index + 1}_axis", (*center, stage.mid_z), f"Stage 2 planet {index + 1} axis"),
(f"planet_{index + 1}_bearing_axis", (*center, stage.mid_z), f"Stage 2 planet {index + 1} bearing pin"),
)
)
radial_ligament = OUTPUT_FLANGE_RADIUS - (OUTPUT_LINK_HOLE_PCD / 2.0 + OUTPUT_LINK_TAP_RADIUS)
print(
f"output_carrier_flange: shaft_d={OUTPUT_SHAFT_RADIUS * 2.0:.2f} "
f"pilot_h={OUTPUT_REGISTER_HEIGHT:.1f} tapped_holes={OUTPUT_LINK_BOLT_COUNT} "
f"pcd={OUTPUT_LINK_HOLE_PCD:.1f} thread_depth={OUTPUT_LINK_THREAD_DEPTH:.1f} "
f"radial_ligament={radial_ligament:.2f}"
)
return make_axis_part_rpart(
part_id="stage2_output_carrier_flange",
body=output,
name="Stage 2 carrier with paired-bearing shaft and output flange",
material=material,
connectors=connectors,
)
def make_planet_rplacement(*, stage: StageSpec, index: int) -> scad.Placement:
"""Place and visually phase one planet at its pitch center."""
center = planet_center_xy(stage=stage, index=index)
carrier_angle = 360.0 * index / PLANET_COUNT
spin = carrier_angle + 180.0 - 180.0 / stage.planet_teeth
print(
f"{stage.stage_id}_planet_{index + 1}_placement: center="
f"({center[0]:.3f},{center[1]:.3f},{stage.bottom_z:.3f}) spin={spin:.2f}"
)
return make_z_rotation_rplacement(
origin=(center[0], center[1], stage.bottom_z),
angle_degrees=spin,
)
def planet_center_xy(*, stage: StageSpec, index: int) -> tuple[float, float]:
"""Return one equally spaced planet pitch center."""
angle = math.radians(360.0 * index / PLANET_COUNT)
return (
stage.planet_center_radius * math.cos(angle),
stage.planet_center_radius * math.sin(angle),
)
def _make_carrier_body_rsolid(
*,
stage: StageSpec,
plate_bottom_z: float,
plate_thickness: float,
pin_bottom_z: float,
pin_radius: float,
hub_radius: float,
arm_width: float,
pad_radius: float,
) -> scad.Solid:
hub = scad.make_cylinder_rsolid(
radius=hub_radius,
height=plate_thickness,
bottom_face_center=(0.0, 0.0, plate_bottom_z),
axis=(0.0, 0.0, 1.0),
)
solids = [hub]
pin_height = plate_bottom_z + plate_thickness - pin_bottom_z
arm_inner_radius = hub_radius - 1.25
arm_outer_radius = stage.planet_center_radius + pad_radius - 0.25
arm_length = arm_outer_radius - arm_inner_radius
arm_center_radius = (arm_inner_radius + arm_outer_radius) / 2.0
for index in range(PLANET_COUNT):
angle = 360.0 * index / PLANET_COUNT
center = planet_center_xy(stage=stage, index=index)
arm = scad.make_box_rsolid(
width=arm_length,
height=arm_width,
depth=plate_thickness,
bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z),
)
solids.append(
scad.rotate_shape(
shape=arm,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=pad_radius,
height=plate_thickness,
bottom_face_center=(center[0], center[1], plate_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=pin_radius,
height=pin_height,
bottom_face_center=(center[0], center[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
carrier = scad.union_rsolid(solids, glue=False)
print(
f"{stage.stage_id}_carrier_body: arm_length={arm_length:.3f} "
f"hub_embed={hub_radius - arm_inner_radius:.3f} pin_height={pin_height:.3f}"
)
return carrier
@@ -0,0 +1,474 @@
"""Serviceable motor shell, reducer case, bearing caps, and electronics cover."""
from __future__ import annotations
import simplecadapi as scad
try:
from .common import (
apply_tags,
make_annulus_rsolid,
make_axis_part_rpart,
make_axial_hole_cutters_rsolids,
radial_centers,
)
from .dimensions import (
FRONT_MOTOR_BEARING,
FRONT_MOTOR_BEARING_CENTER_Z,
HOUSING_INTERFACE_LAND_INNER_RADIUS,
INTERSTAGE_BEARING,
INTERSTAGE_BEARING_CENTER_Z,
MOTOR_INTERFACE_PCD,
MOTOR_SHELL_BOTTOM_Z,
MOTOR_SHELL_INNER_RADIUS,
MOTOR_SHELL_TOP_Z,
MOTOR_STATOR_BOTTOM_Z,
MOTOR_STATOR_TOP_Z,
M3_CLEARANCE_RADIUS,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
OUTPUT_CAP_BOTTOM_Z,
OUTPUT_CAP_CARTRIDGE_TOP_Z,
OUTPUT_CAP_INTERFACE_PCD,
OUTPUT_CAP_TOP_Z,
OUTPUT_CASE_CLAMP_CENTER_Z,
OUTPUT_FLANGE_RADIUS,
PACKAGE_RADIUS,
PCB_BOTTOM_Z,
PCB_STANDOFF_PCD,
REAR_BEARING_CENTER_Z,
REAR_COLUMN_PCD,
REAR_COLUMN_RADIUS,
REAR_COVER_BOTTOM_Z,
REAR_COVER_THICKNESS,
REAR_FASTENER_HOLE_RADIUS,
REAR_SPIDER_BOSS_RADIUS,
REAR_SPIDER_BOTTOM_Z,
REDUCER_HOUSING_BOTTOM_Z,
REDUCER_HOUSING_FRONT_Z,
REDUCER_HOUSING_INNER_RADIUS,
STAGE1_CARRIER_BOTTOM_Z,
STAGE2_CARRIER_BOTTOM_Z,
STAGE_1,
STAGE_2,
)
except ImportError: # Support direct execution from this example directory.
from common import (
apply_tags,
make_annulus_rsolid,
make_axial_hole_cutters_rsolids,
make_axis_part_rpart,
radial_centers,
)
from dimensions import (
FRONT_MOTOR_BEARING,
FRONT_MOTOR_BEARING_CENTER_Z,
HOUSING_INTERFACE_LAND_INNER_RADIUS,
INTERSTAGE_BEARING,
INTERSTAGE_BEARING_CENTER_Z,
M3_CLEARANCE_RADIUS,
MOTOR_INTERFACE_PCD,
MOTOR_SHELL_BOTTOM_Z,
MOTOR_SHELL_INNER_RADIUS,
MOTOR_SHELL_TOP_Z,
MOTOR_STATOR_BOTTOM_Z,
MOTOR_STATOR_TOP_Z,
OUTPUT_BEARING_1_CENTER_Z,
OUTPUT_BEARING_2_CENTER_Z,
OUTPUT_CAP_BOTTOM_Z,
OUTPUT_CAP_CARTRIDGE_TOP_Z,
OUTPUT_CAP_INTERFACE_PCD,
OUTPUT_CAP_TOP_Z,
OUTPUT_CASE_CLAMP_CENTER_Z,
OUTPUT_FLANGE_RADIUS,
PACKAGE_RADIUS,
PCB_BOTTOM_Z,
PCB_STANDOFF_PCD,
REAR_BEARING_CENTER_Z,
REAR_COLUMN_PCD,
REAR_COLUMN_RADIUS,
REAR_COVER_BOTTOM_Z,
REAR_COVER_THICKNESS,
REAR_FASTENER_HOLE_RADIUS,
REAR_SPIDER_BOSS_RADIUS,
REAR_SPIDER_BOTTOM_Z,
REDUCER_HOUSING_BOTTOM_Z,
REDUCER_HOUSING_FRONT_Z,
REDUCER_HOUSING_INNER_RADIUS,
STAGE1_CARRIER_BOTTOM_Z,
STAGE2_CARRIER_BOTTOM_Z,
STAGE_1,
STAGE_2,
)
def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
"""Create the stator sleeve, front attachment land, and rear columns."""
sleeve = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=MOTOR_SHELL_INNER_RADIUS,
bottom_z=MOTOR_SHELL_BOTTOM_Z,
height=MOTOR_SHELL_TOP_Z - MOTOR_SHELL_BOTTOM_Z,
tags=("role.motor_shell", "role.stator_thermal_path"),
)
front_land = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS,
bottom_z=MOTOR_SHELL_TOP_Z - 1.4,
height=1.4,
tags=("role.motor_reducer_mount",),
)
columns = []
for _index, _angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0):
columns.append(
scad.make_cylinder_rsolid(
radius=REAR_COLUMN_RADIUS,
height=REAR_SPIDER_BOTTOM_Z - MOTOR_SHELL_BOTTOM_Z,
bottom_face_center=(center[0], center[1], MOTOR_SHELL_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
shell = scad.union_rsolid(sleeve, front_land, columns, glue=False)
shell = scad.cut_rsolid(
shell,
make_axial_hole_cutters_rsolids(
count=6,
pcd=MOTOR_INTERFACE_PCD,
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=MOTOR_SHELL_TOP_Z - 2.8,
height=3.6,
angle_offset=30.0,
),
make_axial_hole_cutters_rsolids(
count=4,
pcd=REAR_COLUMN_PCD,
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=MOTOR_SHELL_BOTTOM_Z - 1.0,
height=10.4,
),
skip_non_intersecting=False,
)
shell = apply_tags(
shape=shell,
tags=("role.fixed_motor_housing", "group.integrated_bldc_actuator"),
)
print(
f"motor_shell_interface: stator_d={MOTOR_SHELL_INNER_RADIUS * 2.0:.2f} "
f"front_holes=6 rear_columns=4 wall={PACKAGE_RADIUS - MOTOR_SHELL_INNER_RADIUS:.2f}"
)
return make_axis_part_rpart(
part_id="motor_shell",
body=shell,
name="50 mm BLDC motor shell with rear structural columns",
material=material,
connectors=(
("reducer_mount_axis", (0.0, 0.0, MOTOR_SHELL_TOP_Z), "Six-screw reducer mount"),
(
"stator_axis",
(0.0, 0.0, (MOTOR_STATOR_BOTTOM_Z + MOTOR_STATOR_TOP_Z) / 2.0),
"Stator thermal press-fit axis",
),
("rear_spider_axis", (0.0, 0.0, REAR_SPIDER_BOTTOM_Z), "Rear bearing spider mount"),
("rear_cover_axis", (0.0, 0.0, MOTOR_SHELL_BOTTOM_Z), "Rear electronics cover mount"),
),
)
def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
"""Create the reducer sleeve and front motor-bearing bulkhead."""
sleeve = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=REDUCER_HOUSING_INNER_RADIUS,
bottom_z=REDUCER_HOUSING_BOTTOM_Z,
height=REDUCER_HOUSING_FRONT_Z - REDUCER_HOUSING_BOTTOM_Z,
tags=("role.reducer_housing_sleeve",),
)
bulkhead = make_annulus_rsolid(
outer_radius=23.10,
inner_radius=FRONT_MOTOR_BEARING.outer_diameter / 2.0 + 0.05,
bottom_z=REDUCER_HOUSING_BOTTOM_Z,
height=STAGE_1.bottom_z - REDUCER_HOUSING_BOTTOM_Z,
tags=("role.motor_front_bearing_bulkhead",),
)
interstage_divider = make_annulus_rsolid(
outer_radius=23.10,
inner_radius=INTERSTAGE_BEARING.outer_diameter / 2.0 + 0.05,
bottom_z=INTERSTAGE_BEARING_CENTER_Z - INTERSTAGE_BEARING.width / 2.0,
height=INTERSTAGE_BEARING.width,
tags=("role.interstage_bearing_divider",),
)
output_mount_land = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS,
bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2,
height=2.2,
tags=("role.output_cap_mount_land",),
)
housing = scad.union_rsolid(
sleeve,
bulkhead,
interstage_divider,
output_mount_land,
glue=False,
)
housing = scad.cut_rsolid(
housing,
make_axial_hole_cutters_rsolids(
count=6,
pcd=MOTOR_INTERFACE_PCD,
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=REDUCER_HOUSING_BOTTOM_Z - 1.0,
height=STAGE_1.bottom_z - REDUCER_HOUSING_BOTTOM_Z + 2.0,
angle_offset=30.0,
),
make_axial_hole_cutters_rsolids(
count=6,
pcd=OUTPUT_CAP_INTERFACE_PCD,
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2,
height=3.2,
),
skip_non_intersecting=False,
)
housing = apply_tags(
shape=housing,
tags=("role.fixed_reducer_housing", "role.ring_gear_press_fit", "group.integrated_bldc_actuator"),
)
print(
f"reducer_housing: bore_d={REDUCER_HOUSING_INNER_RADIUS * 2.0:.2f} "
f"wall={PACKAGE_RADIUS - REDUCER_HOUSING_INNER_RADIUS:.2f} bulkhead=8.00 "
f"interstage_bearing_z={INTERSTAGE_BEARING_CENTER_Z:.2f} "
f"m3_inner_ligament={MOTOR_INTERFACE_PCD / 2.0 - M3_CLEARANCE_RADIUS - HOUSING_INTERFACE_LAND_INNER_RADIUS:.2f}"
)
return make_axis_part_rpart(
part_id="reducer_housing",
body=housing,
name="50 mm reducer housing with motor bearing bulkhead",
material=material,
connectors=(
("motor_mount_axis", (0.0, 0.0, MOTOR_SHELL_TOP_Z), "Motor shell six-screw interface"),
("front_motor_bearing_axis", (0.0, 0.0, FRONT_MOTOR_BEARING_CENTER_Z), "Front motor bearing seat"),
("stage1_ring_axis", (0.0, 0.0, STAGE_1.mid_z), "Stage 1 fixed ring seat"),
("stage1_carrier_axis", (0.0, 0.0, INTERSTAGE_BEARING_CENTER_Z), "Stage 1 carrier axis"),
("interstage_bearing_axis", (0.0, 0.0, INTERSTAGE_BEARING_CENTER_Z), "Interstage bearing outer seat"),
("stage2_ring_axis", (0.0, 0.0, STAGE_2.mid_z), "Stage 2 fixed ring seat"),
("stage2_carrier_axis", (0.0, 0.0, STAGE2_CARRIER_BOTTOM_Z + 1.50), "Output carrier axis"),
(
"case_clamp_axis",
(0.0, 0.0, OUTPUT_CASE_CLAMP_CENTER_Z),
"External split-clamp datum on reducer sleeve",
),
("output_cap_axis", (0.0, 0.0, REDUCER_HOUSING_FRONT_Z), "Output bearing cap interface"),
),
)
def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
"""Create a four-arm removable rear motor-bearing support."""
bottom_z = REAR_SPIDER_BOTTOM_Z
hub = make_annulus_rsolid(
outer_radius=10.5,
inner_radius=8.05,
bottom_z=bottom_z,
height=5.0,
tags=("role.rear_motor_bearing_seat",),
)
solids = [hub]
for _index, angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0):
arm = scad.make_box_rsolid(
width=12.0,
height=3.0,
depth=5.0,
bottom_face_center=(14.5, 0.0, bottom_z),
)
solids.append(
scad.rotate_shape(
shape=arm,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
solids.append(
scad.make_cylinder_rsolid(
radius=REAR_SPIDER_BOSS_RADIUS,
height=5.0,
bottom_face_center=(center[0], center[1], bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
spider = scad.union_rsolid(solids, glue=False)
spider = scad.cut_rsolid(
spider,
make_axial_hole_cutters_rsolids(
count=4,
pcd=REAR_COLUMN_PCD,
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=bottom_z - 1.0,
height=7.0,
),
skip_non_intersecting=False,
)
spider = apply_tags(
shape=spider,
tags=("role.removable_rear_bearing_spider", "group.integrated_bldc_actuator"),
)
print("rear_bearing_spider: arms=4 bearing_seat_d=16.10 fasteners=4")
return make_axis_part_rpart(
part_id="rear_bearing_spider",
body=spider,
name="Four-arm removable rear motor-bearing spider",
material=material,
connectors=(
("shell_axis", (0.0, 0.0, REAR_SPIDER_BOTTOM_Z), "Motor shell column interface"),
("bearing_axis", (0.0, 0.0, REAR_BEARING_CENTER_Z), "Rear motor bearing outer seat"),
),
)
def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rear cover with PCB standoffs and terminal apertures."""
cover = scad.make_cylinder_rsolid(
radius=PACKAGE_RADIUS,
height=REAR_COVER_THICKNESS,
bottom_face_center=(0.0, 0.0, REAR_COVER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
standoffs = []
for _index, _angle, center in radial_centers(count=4, radius=PCB_STANDOFF_PCD / 2.0, angle_offset=45.0):
standoffs.append(
scad.make_cylinder_rsolid(
radius=2.4,
height=PCB_BOTTOM_Z - REAR_COVER_BOTTOM_Z - REAR_COVER_THICKNESS + 0.1,
bottom_face_center=(center[0], center[1], REAR_COVER_BOTTOM_Z + REAR_COVER_THICKNESS - 0.1),
axis=(0.0, 0.0, 1.0),
)
)
cover = scad.union_rsolid(cover, standoffs, glue=False)
phase_aperture = scad.make_box_rsolid(
width=9.2,
height=7.2,
depth=REAR_COVER_THICKNESS + 2.0,
bottom_face_center=(-11.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0),
)
power_aperture = scad.make_box_rsolid(
width=9.2,
height=7.2,
depth=REAR_COVER_THICKNESS + 2.0,
bottom_face_center=(11.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0),
)
center_service = scad.make_cylinder_rsolid(
radius=3.2,
height=REAR_COVER_THICKNESS + 2.0,
bottom_face_center=(0.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
)
cover = scad.cut_rsolid(
cover,
phase_aperture,
power_aperture,
center_service,
make_axial_hole_cutters_rsolids(
count=4,
pcd=REAR_COLUMN_PCD,
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=REAR_COVER_BOTTOM_Z - 1.0,
height=REAR_COVER_THICKNESS + 2.0,
),
make_axial_hole_cutters_rsolids(
count=4,
pcd=PCB_STANDOFF_PCD,
hole_radius=1.1,
bottom_z=REAR_COVER_BOTTOM_Z - 1.0,
height=PCB_BOTTOM_Z - REAR_COVER_BOTTOM_Z + 2.0,
angle_offset=45.0,
),
skip_non_intersecting=False,
)
cover = apply_tags(
shape=cover,
tags=("role.rear_electronics_cover", "role.terminal_access", "group.integrated_bldc_actuator"),
)
print("rear_cover_access: phase_opening=9.2x7.2 power_can_opening=9.2x7.2 pcb_holes=4")
return make_axis_part_rpart(
part_id="rear_electronics_cover",
body=cover,
name="Rear electronics cover with terminal access",
material=material,
connectors=(
("shell_axis", (0.0, 0.0, MOTOR_SHELL_BOTTOM_Z), "Four-screw motor shell interface"),
("pcb_axis", (0.0, 0.0, PCB_BOTTOM_Z + 0.8), "Controller PCB mounting plane"),
("phase_access", (-11.0, 0.0, REAR_COVER_BOTTOM_Z), "Three-phase terminal access"),
("power_can_access", (11.0, 0.0, REAR_COVER_BOTTOM_Z), "Power and CAN terminal access"),
),
)
def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
"""Create the removable paired-bearing cartridge and front cap."""
bearing_clearance_radius = 12.05
rear_flange = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=bearing_clearance_radius,
bottom_z=OUTPUT_CAP_BOTTOM_Z,
height=3.0,
tags=("role.output_cap_mount_flange",),
)
cartridge = make_annulus_rsolid(
outer_radius=15.0,
inner_radius=bearing_clearance_radius,
bottom_z=OUTPUT_CAP_BOTTOM_Z,
height=OUTPUT_CAP_CARTRIDGE_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 0.1,
tags=("role.paired_output_bearing_seat",),
)
bearing_retainer = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=8.10,
bottom_z=OUTPUT_CAP_CARTRIDGE_TOP_Z - 0.1,
height=0.5,
tags=("role.output_axial_retainer",),
)
outer_lip = make_annulus_rsolid(
outer_radius=PACKAGE_RADIUS,
inner_radius=OUTPUT_FLANGE_RADIUS + 0.30,
bottom_z=OUTPUT_CAP_CARTRIDGE_TOP_Z - 0.1,
height=OUTPUT_CAP_TOP_Z - OUTPUT_CAP_CARTRIDGE_TOP_Z + 0.1,
tags=("role.output_labyrinth_lip",),
)
cap = scad.union_rsolid(rear_flange, cartridge, bearing_retainer, outer_lip, glue=False)
cap = scad.cut_rsolid(
cap,
make_axial_hole_cutters_rsolids(
count=6,
pcd=OUTPUT_CAP_INTERFACE_PCD,
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=OUTPUT_CAP_BOTTOM_Z - 1.0,
height=OUTPUT_CAP_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 2.0,
),
skip_non_intersecting=False,
)
cap = apply_tags(
shape=cap,
tags=("role.removable_output_bearing_cap", "group.integrated_bldc_actuator"),
)
print(
f"output_bearing_cap: bearing_seat_d={bearing_clearance_radius * 2.0:.2f} "
f"bearing_span={OUTPUT_BEARING_2_CENTER_Z - OUTPUT_BEARING_1_CENTER_Z:.1f} fasteners=6"
)
return make_axis_part_rpart(
part_id="output_bearing_cap",
body=cap,
name="Paired output-bearing cartridge and removable cap",
material=material,
connectors=(
("housing_axis", (0.0, 0.0, OUTPUT_CAP_BOTTOM_Z), "Six-screw housing interface"),
("bearing_1_axis", (0.0, 0.0, OUTPUT_BEARING_1_CENTER_Z), "Rear output bearing seat"),
("bearing_2_axis", (0.0, 0.0, OUTPUT_BEARING_2_CENTER_Z), "Front output bearing seat"),
("case_mount_axis", (0.0, 0.0, OUTPUT_CAP_TOP_Z), "Fixed actuator case datum"),
),
)
@@ -0,0 +1,98 @@
"""Build, validate, replay, and export the integrated BLDC joint actuator."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import simplecadapi as scad
from assembly import make_integrated_bldc_joint_actuator_rassembly
from common import ground_compound
from dimensions import (
MOTOR_AIR_GAP,
MOTOR_POLE_COUNT,
MOTOR_SLOT_COUNT,
PACKAGE_RADIUS,
PACKAGE_STRUCTURAL_BOTTOM_Z,
PACKAGE_TOP_Z,
TOTAL_REDUCTION,
validate_design_dimensions,
)
from materials import make_actuator_materials_rdict
sys.setrecursionlimit(30000)
OUT_DIR = Path("examples/out/integrated_bldc_joint_actuator")
def build_integrated_bldc_joint_actuator():
"""Build the replayable actuator and return product and interchange outputs."""
validate_design_dimensions()
materials = make_actuator_materials_rdict()
with scad.GraphSession(graph_id="integrated_50mm_bldc_joint_actuator") as session:
assembly = make_integrated_bldc_joint_actuator_rassembly(materials=materials)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
ground_compound(label="integrated_actuator_preview", compound=preview)
session_json = scad.export_session_json(session=session, indent=2)
model_json = scad.export_model_json(session=session, indent=2)
return assembly, preview, model_json, session_json
def main() -> None:
"""Generate canonical model JSON, STEP, and optional FreeCAD output."""
OUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUT_DIR / "integrated_bldc_joint_actuator.model.json"
session_path = OUT_DIR / "integrated_bldc_joint_actuator.session.json"
step_path = OUT_DIR / "integrated_bldc_joint_actuator.step"
fcstd_path = OUT_DIR / "integrated_bldc_joint_actuator.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json, session_json = build_integrated_bldc_joint_actuator()
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
imported = scad.import_model_json(json_str=model_json)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
payload = json.loads(model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
output_path=str(fcstd_path.resolve()),
document_name="Integrated50mmBLDCJointActuator",
freecad_cmd=None,
)
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})"
print(f"envelope_diameter={PACKAGE_RADIUS * 2.0:.1f}")
print(f"structural_length={PACKAGE_TOP_Z - PACKAGE_STRUCTURAL_BOTTOM_Z:.1f}")
print(f"motor_topology={MOTOR_SLOT_COUNT}_slot_{MOTOR_POLE_COUNT}_pole")
print(f"motor_air_gap={MOTOR_AIR_GAP:.2f}")
print(f"total_reduction={TOTAL_REDUCTION:.1f}")
print(f"assembly={assembly.assembly_id}")
print(f"components={len(assembly.component_ids())}")
print(f"constraints={len(assembly.constraint_ids())}")
print(f"preview_solids={len(preview.get_solids())}")
print(f"preview_volume={preview.get_volume():.3f}")
print(f"imported_keys={','.join(sorted(imported.keys()))}")
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"session={session_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
"""Material records for structural, magnetic, and electronic parts."""
from __future__ import annotations
import simplecadapi as scad
def make_actuator_materials_rdict() -> dict[str, scad.Material]:
"""Create the materials used by Case 20."""
materials = {
"housing": scad.make_material_rmaterial(
material_id="aluminum_6061_t6",
name="Hard-anodized 6061-T6 aluminum",
density=2.70e-6,
density_unit="kg/mm^3",
color=(0.12, 0.15, 0.18),
),
"carrier": scad.make_material_rmaterial(
material_id="aluminum_7075_t6",
name="7075-T6 aluminum",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.72, 0.74, 0.78),
),
"gear": scad.make_material_rmaterial(
material_id="case_hardened_gear_steel",
name="Case-hardened alloy gear steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.46, 0.50, 0.56),
),
"electrical_steel": scad.make_material_rmaterial(
material_id="laminated_electrical_steel",
name="Laminated electrical steel",
density=7.65e-6,
density_unit="kg/mm^3",
color=(0.20, 0.27, 0.35),
),
"copper": scad.make_material_rmaterial(
material_id="enameled_copper",
name="Enameled copper winding",
density=8.96e-6,
density_unit="kg/mm^3",
color=(0.78, 0.27, 0.06),
),
"magnet": scad.make_material_rmaterial(
material_id="ndfeb_n42sh",
name="NdFeB N42SH magnet",
density=7.50e-6,
density_unit="kg/mm^3",
color=(0.14, 0.34, 0.75),
),
"pcb": scad.make_material_rmaterial(
material_id="fr4_copper_laminate",
name="FR-4 copper laminate",
density=1.85e-6,
density_unit="kg/mm^3",
color=(0.03, 0.42, 0.16),
),
"terminal": scad.make_material_rmaterial(
material_id="high_temperature_terminal_polymer",
name="High-temperature connector polymer",
density=1.45e-6,
density_unit="kg/mm^3",
color=(0.88, 0.70, 0.16),
),
}
print("materials: " + ",".join(sorted(materials)))
return materials
@@ -0,0 +1,378 @@
"""True 12-slot/14-pole BLDC stator and direct-drive rotor assemblies."""
from __future__ import annotations
import simplecadapi as scad
try:
from .common import (
apply_tags,
connector_ref,
ground_constraint_report,
make_annulus_rsolid,
make_axis_part_rpart,
make_z_rotation_rplacement,
radial_centers,
)
from .dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CLEARANCE_FACTOR,
GEAR_HEIGHT,
HELIX_ANGLE,
MOTOR_MAGNET_OUTER_RADIUS,
MOTOR_MAGNET_TANGENTIAL_WIDTH,
MOTOR_POLE_COUNT,
MOTOR_ROTOR_BACKIRON_RADIUS,
MOTOR_ROTOR_BOTTOM_Z,
MOTOR_ROTOR_TOP_Z,
MOTOR_SHAFT_RADIUS,
MOTOR_SHELL_INNER_RADIUS,
MOTOR_SLOT_COUNT,
MOTOR_STATOR_BOTTOM_Z,
MOTOR_STATOR_OUTER_RADIUS,
MOTOR_STATOR_TOOTH_INNER_RADIUS,
MOTOR_STATOR_TOOTH_WIDTH,
MOTOR_STATOR_TOP_Z,
MOTOR_STATOR_YOKE_INNER_RADIUS,
PRESSURE_ANGLE,
REAR_BEARING_CENTER_Z,
STAGE_1,
)
except ImportError: # Support direct execution from this example directory.
from common import (
apply_tags,
connector_ref,
ground_constraint_report,
make_annulus_rsolid,
make_axis_part_rpart,
make_z_rotation_rplacement,
radial_centers,
)
from dimensions import (
ADDENDUM_FACTOR,
BACKLASH,
CLEARANCE_FACTOR,
GEAR_HEIGHT,
HELIX_ANGLE,
MOTOR_MAGNET_OUTER_RADIUS,
MOTOR_MAGNET_TANGENTIAL_WIDTH,
MOTOR_POLE_COUNT,
MOTOR_ROTOR_BACKIRON_RADIUS,
MOTOR_ROTOR_BOTTOM_Z,
MOTOR_ROTOR_TOP_Z,
MOTOR_SHAFT_RADIUS,
MOTOR_SHELL_INNER_RADIUS,
MOTOR_SLOT_COUNT,
MOTOR_STATOR_BOTTOM_Z,
MOTOR_STATOR_OUTER_RADIUS,
MOTOR_STATOR_TOOTH_INNER_RADIUS,
MOTOR_STATOR_TOOTH_WIDTH,
MOTOR_STATOR_TOP_Z,
MOTOR_STATOR_YOKE_INNER_RADIUS,
PRESSURE_ANGLE,
REAR_BEARING_CENTER_Z,
STAGE_1,
)
def make_bldc_stator_rassembly(
*,
steel_material: scad.Material,
copper_material: scad.Material,
) -> scad.Assembly:
"""Build a laminated 12-slot stator and twelve fixed winding packs."""
core = _make_stator_core_rpart(material=steel_material)
winding = _make_winding_pack_rpart(material=copper_material)
stator = scad.make_assembly_rassembly(
assembly_id="bldc_12_slot_stator",
name="12-slot laminated stator with discrete copper slot packs",
)
stator = scad.add_component_rassembly(
assembly=stator,
item=core,
component_id="stator_core",
placement=scad.identity_placement_rplacement(),
name="Laminated stator core",
)
stator = scad.ground_component_rassembly(assembly=stator, component_id="stator_core")
for index, angle, _center in radial_centers(
count=MOTOR_SLOT_COUNT,
radius=0.0,
angle_offset=0.0,
):
component_id = f"winding_{index + 1:02d}"
stator = scad.add_component_rassembly(
assembly=stator,
item=winding,
component_id=component_id,
placement=make_z_rotation_rplacement(origin=(0.0, 0.0, 0.0), angle_degrees=angle),
name=f"Slot winding pack {index + 1}",
)
stator = scad.add_fixed_constraint_rassembly(
assembly=stator,
constraint_id=f"{component_id}_potted_to_core",
connector_a=connector_ref(component_id="stator_core", connector_id=component_id),
connector_b=connector_ref(component_id=component_id, connector_id="mount_axis"),
name=f"Winding {index + 1} varnish and potting retention",
)
stator = scad.forward_connector_rassembly(
assembly=stator,
connector_id="shell_axis",
source_component_id="stator_core",
source_connector_id="shell_axis",
name="Stator press-fit axis",
offset=None,
)
stator = scad.solve_assembly_constraints_rassembly(assembly=stator, strict=True)
ground_constraint_report(label="stator", assembly=stator)
return stator
def make_bldc_rotor_rassembly(
*,
steel_material: scad.Material,
magnet_material: scad.Material,
) -> scad.Assembly:
"""Build the rotor, bonded magnets, shaft, and integrated stage-1 sun."""
core = _make_rotor_shaft_sun_rpart(material=steel_material)
magnet = _make_rotor_magnet_rpart(material=magnet_material)
rotor = scad.make_assembly_rassembly(
assembly_id="direct_coupled_bldc_rotor",
name="14-pole BLDC rotor with integrated stage-1 sun shaft",
)
rotor = scad.add_component_rassembly(
assembly=rotor,
item=core,
component_id="rotor_core_shaft_sun",
placement=scad.identity_placement_rplacement(),
name="Rotor back iron, shaft, and stage-1 sun",
)
rotor = scad.ground_component_rassembly(assembly=rotor, component_id="rotor_core_shaft_sun")
for index, angle, _center in radial_centers(count=MOTOR_POLE_COUNT, radius=0.0):
component_id = f"magnet_{index + 1:02d}"
rotor = scad.add_component_rassembly(
assembly=rotor,
item=magnet,
component_id=component_id,
placement=make_z_rotation_rplacement(origin=(0.0, 0.0, 0.0), angle_degrees=angle),
name=f"Bonded rotor magnet {index + 1}",
)
rotor = scad.add_fixed_constraint_rassembly(
assembly=rotor,
constraint_id=f"{component_id}_bonded_to_rotor",
connector_a=connector_ref(component_id="rotor_core_shaft_sun", connector_id=component_id),
connector_b=connector_ref(component_id=component_id, connector_id="bond_axis"),
name=f"Magnet {index + 1} adhesive and sleeve retention",
)
for connector_id in (
"rotor_axis",
"rear_bearing_axis",
"front_bearing_axis",
"stage1_sun_axis",
):
rotor = scad.forward_connector_rassembly(
assembly=rotor,
connector_id=connector_id,
source_component_id="rotor_core_shaft_sun",
source_connector_id=connector_id,
name=connector_id.replace("_", " "),
offset=None,
)
rotor = scad.solve_assembly_constraints_rassembly(assembly=rotor, strict=True)
ground_constraint_report(label="rotor", assembly=rotor)
print(
f"rotor_direct_coupling: shaft_d={MOTOR_SHAFT_RADIUS * 2.0:.1f} "
f"magnets={MOTOR_POLE_COUNT} stage1_sun_teeth={STAGE_1.sun_teeth}"
)
return rotor
def _make_stator_core_rpart(*, material: scad.Material) -> scad.Part:
yoke = make_annulus_rsolid(
outer_radius=MOTOR_STATOR_OUTER_RADIUS,
inner_radius=MOTOR_STATOR_YOKE_INNER_RADIUS,
bottom_z=MOTOR_STATOR_BOTTOM_Z,
height=MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z,
tags=("role.stator_back_iron",),
)
tooth_length = MOTOR_STATOR_YOKE_INNER_RADIUS - MOTOR_STATOR_TOOTH_INNER_RADIUS + 0.40
tooth_center_radius = MOTOR_STATOR_TOOTH_INNER_RADIUS + tooth_length / 2.0
teeth = []
for _index, angle, _center in radial_centers(count=MOTOR_SLOT_COUNT, radius=0.0):
tooth = scad.make_box_rsolid(
width=tooth_length,
height=MOTOR_STATOR_TOOTH_WIDTH,
depth=MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z,
bottom_face_center=(tooth_center_radius, 0.0, MOTOR_STATOR_BOTTOM_Z),
)
teeth.append(
scad.rotate_shape(
shape=tooth,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
)
core = scad.union_rsolid(yoke, teeth, glue=False)
core = apply_tags(
shape=core,
tags=("role.stator_core", "role.stator_thermal_path", "group.bldc_motor"),
)
connectors = [
(
"shell_axis",
(0.0, 0.0, (MOTOR_STATOR_BOTTOM_Z + MOTOR_STATOR_TOP_Z) / 2.0),
"Stator press-fit axis",
)
]
part = make_axis_part_rpart(
part_id="stator_core",
body=core,
name="12-slot laminated electrical-steel stator stack",
material=material,
connectors=connectors,
)
for index, angle, _center in radial_centers(
count=MOTOR_SLOT_COUNT,
radius=0.0,
angle_offset=0.0,
):
rotation = make_z_rotation_rplacement(origin=(0.0, 0.0, 0.0), angle_degrees=angle)
connector = scad.make_placement_connector_rconnector(
connector_id=f"winding_{index + 1:02d}",
placement=rotation,
name=f"Slot winding {index + 1} retention datum",
)
part = scad.add_connector_rpart(part=part, connector=connector)
print(
f"stator_core_geometry: slots={MOTOR_SLOT_COUNT} active_length="
f"{MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z:.1f} radial_fit_clearance="
f"{MOTOR_SHELL_INNER_RADIUS - MOTOR_STATOR_OUTER_RADIUS:.2f}"
)
return part
def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part:
side_depth = MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z + 0.4
side_bottom_z = MOTOR_STATOR_BOTTOM_Z - 0.2
side_positive = scad.make_box_rsolid(
width=4.2,
height=1.2,
depth=side_depth,
bottom_face_center=(17.55, 2.1, side_bottom_z),
)
side_negative = scad.make_box_rsolid(
width=4.2,
height=1.2,
depth=side_depth,
bottom_face_center=(17.55, -2.1, side_bottom_z),
)
rear_end_turn = scad.make_box_rsolid(
width=4.2,
height=5.4,
depth=0.9,
bottom_face_center=(17.55, 0.0, MOTOR_STATOR_BOTTOM_Z - 1.0),
)
front_end_turn = scad.make_box_rsolid(
width=4.2,
height=5.4,
depth=0.9,
bottom_face_center=(17.55, 0.0, MOTOR_STATOR_TOP_Z + 0.1),
)
winding = scad.union_rsolid(
side_positive,
side_negative,
rear_end_turn,
front_end_turn,
glue=False,
)
winding = apply_tags(
shape=winding,
tags=("role.copper_slot_winding", "group.three_phase_windings"),
)
return make_axis_part_rpart(
part_id="reusable_slot_winding",
body=winding,
name="Reusable four-segment copper tooth winding pack",
material=material,
connectors=(("mount_axis", (0.0, 0.0, 0.0), "Core potting datum"),),
)
def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part:
shaft_bottom_z = REAR_BEARING_CENTER_Z - 3.0
shaft = scad.make_cylinder_rsolid(
radius=MOTOR_SHAFT_RADIUS,
height=STAGE_1.top_z - shaft_bottom_z,
bottom_face_center=(0.0, 0.0, shaft_bottom_z),
axis=(0.0, 0.0, 1.0),
)
back_iron = scad.make_cylinder_rsolid(
radius=MOTOR_ROTOR_BACKIRON_RADIUS,
height=MOTOR_ROTOR_TOP_Z - MOTOR_ROTOR_BOTTOM_Z,
bottom_face_center=(0.0, 0.0, MOTOR_ROTOR_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=STAGE_1.sun_teeth,
module=STAGE_1.module,
pressure_angle=PRESSURE_ANGLE,
helix_angle=HELIX_ANGLE,
gear_height=GEAR_HEIGHT,
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
sun = scad.translate_shape(shape=sun, vector=(0.0, 0.0, STAGE_1.bottom_z))
rotor = scad.union_rsolid(shaft, back_iron, sun, glue=False)
rotor = apply_tags(
shape=rotor,
tags=("role.rotor_back_iron", "role.direct_drive_shaft", "role.stage1.sun_gear", "group.bldc_motor"),
)
part = make_axis_part_rpart(
part_id="rotor_core_shaft_sun",
body=rotor,
name="Integrated rotor back iron, 8 mm shaft, and stage-1 sun",
material=material,
connectors=(
("rotor_axis", (0.0, 0.0, -2.5), "Motor rotation axis"),
("rear_bearing_axis", (0.0, 0.0, REAR_BEARING_CENTER_Z), "Rear motor bearing shaft seat"),
("front_bearing_axis", (0.0, 0.0, -2.5), "Front motor bearing shaft seat"),
("stage1_sun_axis", (0.0, 0.0, STAGE_1.mid_z), "Integrated stage-1 sun axis"),
),
)
for index, angle, _center in radial_centers(count=MOTOR_POLE_COUNT, radius=0.0):
connector = scad.make_placement_connector_rconnector(
connector_id=f"magnet_{index + 1:02d}",
placement=make_z_rotation_rplacement(origin=(0.0, 0.0, 0.0), angle_degrees=angle),
name=f"Magnet {index + 1} bond datum",
)
part = scad.add_connector_rpart(part=part, connector=connector)
return part
def _make_rotor_magnet_rpart(*, material: scad.Material) -> scad.Part:
half_width = MOTOR_MAGNET_TANGENTIAL_WIDTH / 2.0
outer_x = (MOTOR_MAGNET_OUTER_RADIUS**2 - half_width**2) ** 0.5
radial_depth = outer_x - MOTOR_ROTOR_BACKIRON_RADIUS + 0.05
magnet = scad.make_box_rsolid(
width=radial_depth,
height=MOTOR_MAGNET_TANGENTIAL_WIDTH,
depth=MOTOR_ROTOR_TOP_Z - MOTOR_ROTOR_BOTTOM_Z,
bottom_face_center=(outer_x - radial_depth / 2.0, 0.0, MOTOR_ROTOR_BOTTOM_Z),
)
magnet = apply_tags(shape=magnet, tags=("role.rotor_magnet", "group.rotor_magnets"))
print(
f"rotor_magnet_envelope: corner_radius={MOTOR_MAGNET_OUTER_RADIUS:.2f} "
f"air_gap={MOTOR_STATOR_TOOTH_INNER_RADIUS - MOTOR_MAGNET_OUTER_RADIUS:.2f}"
)
return make_axis_part_rpart(
part_id="reusable_rotor_magnet",
body=magnet,
name="Reusable bonded NdFeB rotor magnet",
material=material,
connectors=(("bond_axis", (0.0, 0.0, 0.0), "Rotor bond datum"),),
)
+22
View File
@@ -0,0 +1,22 @@
# SimpleCADAPI Examples
Run examples from the repository root with `uv run python <path>`.
Generated STEP/STL/JSON files are written to `examples/out/`, which is ignored by git.
## Examples
- `01_basic_modeling.py` — functional shape modeling, booleans, and STEP/STL export.
- `02_graph_replay.py` — `GraphSession`, canonical model JSON export, and replay.
- `03_expressions.py` — expression parameters captured in a replayable model graph.
- `05_loft_sweep_revolve.py` — profile operations: revolve, loft, and sweep.
- `06_parametric_gear_model.py` — lightweight involute spur gear model JSON example for replay/export tests.
- `07_serialization_operation_tree.py` — compact serialization demo showing how source calls map to canonical operation-tree nodes, including expressions, primitive lowering, features, booleans, transforms, patterns, and detail operations.
- `13_cycloidal_reducer.py` — compact 50 mm diameter, 10 mm tall, 10:1 cycloidal reducer assembly with twin segmented B-spline cycloidal discs, 180-degree opposed input eccentric cams, 18-degree half-lobe tooth-index phase, three-hole input/output disks, and assembly constraints.
- `14_ball_bearing.py` — parameterized ball bearing standard assembly with grooved inner/outer race rings, direct sphere rolling elements, stable ring component IDs, ring axis connectors, an inner-to-outer revolute constraint, and a demo shaft/housing bound through those connectors.
- `15_cached_mesh_obj_export.py` — developer-facing cached-mesh example that builds a normal Solid, bypasses the public STL exporter, reads the internal mesh cache, and writes a Wavefront OBJ file.
- `16_compact_two_stage_planetary_reducer/` — modular 58.8 mm diameter, 30 mm tall, 20:1 two-stage herringbone planetary reducer with through-bolted actuator housing bosses, sealed input/output end caps, realistic output register pads, reusable stdlib ball bearing placements, graph/model JSON replay, STEP export, solved gear constraints, and a `collision_probe.py` static verifier run.
- `17_static_collision_verifier.py` — static current-pose verifier example using internal cached meshes and python-fcl to report over-tolerance contact penetration.
- `18_leg_wheel_robot_dog_leg/` — planar leg-wheel module using three reused reducer actuator modules, a fixed motor-can part, a compact coaxial thigh/knee-drive actuator stack, a thigh output-flange-bolted upper link, a coaxial knee-drive output crank with 6-hole output flange pattern, a true parallelogram pushrod linkage whose knee-side `BB'` ear is integrated into the shank plate, knee bearing retainer holes, wheel-hub housing/output bolt circles, graph/model JSON replay, STEP/FCStd export, and a leg-level `collision_probe.py` packaging check.
- `19_four_planet_planetary_reducer/` — exposed single-stage 3.5:1 fixed-ring planetary gearset with one input sun gear, four equally spaced planet gears, an internal ring gear, a four-pin output carrier, solved revolute/external gear/internal belt-equivalent mesh constraints, graph/model JSON replay, STEP export, and FCStd export.
- `20_five_axis_desktop_robot_arm/` — five-revolute-axis desktop robot arm inspired by the reference image, using five reused Example 16 reducer actuator modules with an improved rear-service motor package, explicit part/interface validation before assembly, base yaw, shoulder/elbow/wrist pitch, tool roll, bolted housing/output flange interfaces, sensor face detail, graph/model JSON replay, STEP export, and FCStd export.
- `20_integrated_bldc_joint_actuator/` — compact 50 mm OD joint actuator with a real 12-slot/14-pole inner-rotor BLDC motor, integrated rotor-shaft/stage-1 sun, 20:1 two-stage herringbone planetary reducer, serviceable split housing, paired output bearings, circular ESC PCB, rear phase and power/CAN terminals, graph/model JSON replay, STEP export, and FCStd export.