feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
+5
View File
@@ -4,3 +4,8 @@ out/
*.json
*.FCStd
*.FCBak
*.fcstd
*.fcbak
*.scene.zip
*.zip
*.log
@@ -1,31 +0,0 @@
"""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")
-39
View File
@@ -1,39 +0,0 @@
"""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")
-33
View File
@@ -1,33 +0,0 @@
"""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,108 @@
"""Declare, propagate, validate, and serialize a dimension tolerance chain.
Run from the repository root with:
uv run python examples/04_dimension_tolerance_chain.py
"""
import json
from pathlib import Path
import simplecadapi as scad
OUT = Path("examples/out")
@scad.model(graph_id="dimension_tolerance_chain")
def build_model():
housing_span = scad.var(
name="housing_span",
default=100.0,
unit="mm",
tolerance=0.15,
comment="Internal housing span",
)
bearing_width = scad.var(
name="bearing_width",
default=2.0,
unit="cm",
tolerance=(-0.04, 0.05),
tolerance_unit="mm",
comment="Bearing width",
)
spacer_width = scad.var(
name="spacer_width",
default=79.4,
unit="mm",
tolerance=0.05,
comment="Spacer width",
)
axial_clearance = housing_span - bearing_width - spacer_width
worst_case = scad.analyze_tolerance(
value=axial_clearance,
method="worst_case",
)
rss = scad.analyze_tolerance(value=axial_clearance, method="rss")
housing = scad.make_box_rsolid(
width=housing_span,
height=10.0,
depth=10.0,
tag_prefix="tolerance_chain.housing",
result_tag="part.tolerance_chain.housing",
)
session = scad.get_active_session()
if session is None:
raise RuntimeError("dimension tolerance model has no active session")
session.require_tolerance(
value=axial_clearance,
tolerance=(-0.25, 0.24),
tolerance_unit="mm",
method="worst_case",
name="axial_clearance",
)
report = session.validate_tolerances(raise_on_failure=True)
scad.capture_result(value=housing)
return {
"housing": housing,
"worst_case": worst_case,
"rss": rss,
"report": report,
}
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
result = build_model()
report_data = result.value
(OUT / "dimension_tolerance_chain.model.json").write_text(
result.model_json,
encoding="utf-8",
)
worst_case = report_data["worst_case"]
rss = report_data["rss"]
print("housing_volume", round(report_data["housing"].get_volume(), 3))
print(
"worst_case",
round(worst_case.nominal, 3),
round(worst_case.lower_bound, 3),
round(worst_case.upper_bound, 3),
)
print("result_unit", worst_case.dimension.name, worst_case.unit.symbol)
print(
"rss",
round(rss.nominal, 3),
round(rss.lower_bound, 3),
round(rss.upper_bound, 3),
)
print("requirements_passed", report_data["report"].passed)
print(
"serialized_tolerance_graph",
"tolerance_graph" in json.loads(result.model_json),
)
if __name__ == "__main__":
main()
@@ -1,35 +0,0 @@
"""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))
@@ -1,192 +0,0 @@
"""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()
@@ -1,312 +0,0 @@
"""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)))
+590 -262
View File
@@ -21,7 +21,6 @@ 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"
@@ -30,7 +29,7 @@ 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,
sketch=sketch,
require_fully_constrained=True,
)
points = sorted(
@@ -54,228 +53,513 @@ def _solve_and_report(name: str, sketch: scad.Sketch) -> None:
)
@scad.requires_session
def _promote_face(name: str, sketch: scad.Sketch):
_solve_and_report(name, sketch)
_solve_and_report(name=name, sketch=sketch)
return scad.make_face_from_sketch_rface(
sketch,
sketch=sketch,
require_fully_constrained=True,
)
@scad.requires_session
def make_rect_profile(name, x0, y0, width, height):
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.make_sketch_rsketch(name=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_point_rsketch(sketch=sketch, point_id="p0", x=x0, y=y0)
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id="p1",
x=x0 + width,
y=y0,
)
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id="p2",
x=x0 + width,
y=y0 + height,
)
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id="p3",
x=x0,
y=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.add_line_rsketch(
sketch=sketch,
entity_id="bottom",
start="p0",
end="p1",
)
sketch = scad.add_line_rsketch(
sketch=sketch,
entity_id="right",
start="p1",
end="p2",
)
sketch = scad.add_line_rsketch(
sketch=sketch,
entity_id="top",
start="p2",
end="p3",
)
sketch = scad.add_line_rsketch(
sketch=sketch,
entity_id="left",
start="p3",
end="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)
sketch = scad.constrain_horizontal_rsketch(sketch=sketch, line="bottom")
sketch = scad.constrain_vertical_rsketch(sketch=sketch, line="right")
sketch = scad.constrain_parallel_rsketch(
sketch=sketch,
a="bottom",
b="top",
)
sketch = scad.constrain_parallel_rsketch(
sketch=sketch,
a="left",
b="right",
)
sketch = scad.constrain_perpendicular_rsketch(
sketch=sketch,
a="bottom",
b="right",
)
sketch = scad.constrain_equal_length_rsketch(
sketch=sketch,
a="bottom",
b="top",
)
sketch = scad.constrain_equal_length_rsketch(
sketch=sketch,
a="left",
b="right",
)
sketch = scad.constrain_distance_rsketch(
sketch=sketch,
a="p0",
b="p1",
value=width,
)
sketch = scad.constrain_distance_rsketch(
sketch=sketch,
a="p0",
b="p3",
value=height,
)
sketch = scad.constrain_fix_rsketch(sketch=sketch, target="p0")
return _promote_face(name=name, sketch=sketch)
@scad.requires_session
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)
sketch = scad.make_sketch_rsketch(name=name, plane="XY")
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id="center",
x=center_x,
y=center_y,
)
sketch = scad.add_circle_rsketch(
sketch=sketch,
entity_id=circle_id,
center="center",
radius=radius,
)
sketch = scad.constrain_fix_rsketch(sketch=sketch, target="center")
sketch = scad.constrain_radius_rsketch(
sketch=sketch,
circle=circle_id,
value=radius,
)
return _promote_face(name=name, sketch=sketch)
@scad.requires_session
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.make_sketch_rsketch(name=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)
for point_id, x, y in (
("center", center_x, center_y),
("left", center_x - half_w, center_y),
("top", center_x, center_y + half_h),
("right", center_x + half_w, center_y),
("bottom", center_x, center_y - half_h),
("guide_upper_start", center_x - half_w, center_y + guide_gap),
("guide_upper_end", center_x, center_y + half_h + guide_gap),
("guide_lower_start", center_x + half_w, center_y - guide_gap),
("guide_lower_end", center_x, center_y - half_h - guide_gap),
):
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id=point_id,
x=x,
y=y,
)
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)
for entity_id, start, end, construction in (
("bottom_left", "left", "bottom", False),
("right_bottom", "bottom", "right", False),
("top_right", "right", "top", False),
("left_top", "top", "left", False),
("guide_upper", "guide_upper_start", "guide_upper_end", True),
("guide_lower", "guide_lower_start", "guide_lower_end", True),
):
sketch = scad.add_line_rsketch(
sketch=sketch,
entity_id=entity_id,
start=start,
end=end,
construction=construction,
)
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=sketch, target="center")
for function, a, b, value in (
(scad.constrain_distance_x_rsketch, "left", "center", half_w),
(scad.constrain_distance_y_rsketch, "left", "center", 0.0),
(scad.constrain_distance_x_rsketch, "center", "right", half_w),
(scad.constrain_distance_y_rsketch, "center", "right", 0.0),
(scad.constrain_distance_x_rsketch, "center", "top", 0.0),
(scad.constrain_distance_y_rsketch, "center", "top", half_h),
(scad.constrain_distance_x_rsketch, "bottom", "center", 0.0),
(scad.constrain_distance_y_rsketch, "bottom", "center", half_h),
):
sketch = function(sketch=sketch, a=a, b=b, value=value)
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)
for function, a, b in (
(scad.constrain_parallel_rsketch, "left_top", "right_bottom"),
(scad.constrain_parallel_rsketch, "top_right", "bottom_left"),
(scad.constrain_equal_length_rsketch, "left_top", "top_right"),
(scad.constrain_equal_length_rsketch, "top_right", "right_bottom"),
(scad.constrain_equal_length_rsketch, "right_bottom", "bottom_left"),
):
sketch = function(sketch=sketch, a=a, b=b)
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")
for function, a, b, value in (
(scad.constrain_distance_x_rsketch, "left", "guide_upper_start", 0.0),
(scad.constrain_distance_y_rsketch, "left", "guide_upper_start", guide_gap),
(scad.constrain_distance_x_rsketch, "top", "guide_upper_end", 0.0),
(scad.constrain_distance_y_rsketch, "top", "guide_upper_end", guide_gap),
(scad.constrain_distance_x_rsketch, "guide_lower_start", "right", 0.0),
(scad.constrain_distance_y_rsketch, "guide_lower_start", "right", guide_gap),
(scad.constrain_distance_x_rsketch, "guide_lower_end", "bottom", 0.0),
(scad.constrain_distance_y_rsketch, "guide_lower_end", "bottom", guide_gap),
):
sketch = function(sketch=sketch, a=a, b=b, value=value)
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)
for function, a, b in (
(scad.constrain_parallel_rsketch, "guide_upper", "guide_lower"),
(scad.constrain_parallel_rsketch, "guide_upper", "right_bottom"),
(scad.constrain_parallel_rsketch, "guide_lower", "left_top"),
(scad.constrain_equal_length_rsketch, "guide_upper", "right_bottom"),
(scad.constrain_equal_length_rsketch, "guide_lower", "left_top"),
):
sketch = function(sketch=sketch, a=a, b=b)
return _promote_face(name=name, sketch=sketch)
@scad.requires_session
def make_curve_guided_relief_profile(name, center_x, center_y, radius, guide_span):
sketch = scad.make_sketch_rsketch(name, plane="XY")
sketch = scad.make_sketch_rsketch(name=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)
for point_id, x, y in (
("center", center_x, center_y),
("rim", center_x + radius, center_y),
("clearance_center", center_x, center_y),
("upper_left", center_x - guide_span, center_y + radius),
("upper_right", center_x + guide_span, center_y + radius),
("lower_left", center_x - guide_span, center_y - radius),
("lower_right", center_x + guide_span, center_y - radius),
):
sketch = scad.add_point_rsketch(
sketch=sketch,
point_id=point_id,
x=x,
y=y,
)
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.add_circle_rsketch(
sketch=sketch,
entity_id="relief",
center="center",
radius=radius,
)
sketch = scad.add_circle_rsketch(
sketch=sketch,
entity_id="clearance",
center="clearance_center",
radius=radius,
construction=True,
)
for entity_id, start, end in (
("radius_probe", "center", "rim"),
("upper_rail", "upper_left", "upper_right"),
("lower_rail", "lower_left", "lower_right"),
):
sketch = scad.add_line_rsketch(
sketch=sketch,
entity_id=entity_id,
start=start,
end=end,
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_fix_rsketch(sketch=sketch, target="center")
sketch = scad.constrain_radius_rsketch(
sketch=sketch,
circle="relief",
value=radius,
)
sketch = scad.constrain_point_on_rsketch(
sketch=sketch,
point="rim",
entity="relief",
)
sketch = scad.constrain_horizontal_rsketch(
sketch=sketch,
line="radius_probe",
)
sketch = scad.constrain_length_rsketch(
sketch=sketch,
line="radius_probe",
value=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_concentric_rsketch(
sketch=sketch,
a="relief",
b="clearance",
)
sketch = scad.constrain_equal_radius_rsketch(
sketch=sketch,
a="relief",
b="clearance",
)
sketch = scad.constrain_horizontal_rsketch(
sketch=sketch,
line="upper_rail",
)
sketch = scad.constrain_horizontal_rsketch(
sketch=sketch,
line="lower_rail",
)
sketch = scad.constrain_tangent_rsketch(
sketch=sketch,
a="upper_rail",
b="relief",
)
sketch = scad.constrain_tangent_rsketch(
sketch=sketch,
a="lower_rail",
b="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)
for a, b, value in (
("center", "upper_left", -guide_span),
("center", "upper_right", guide_span),
("center", "lower_left", -guide_span),
("center", "lower_right", guide_span),
):
sketch = scad.constrain_distance_x_rsketch(
sketch=sketch,
a=a,
b=b,
value=value,
)
return _promote_face(name=name, sketch=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")
@scad.model(graph_id="constrained_sketch")
def build_model():
plate_w = scad.var(name="plate_w", default=96.0, comment="plate width")
plate_h = scad.var(name="plate_h", default=54.0, comment="plate height")
plate_t = scad.var(name="plate_t", default=6.0, comment="plate thickness")
boss_r = scad.var(
name="boss_r",
default=14.0,
comment="raised center boss radius",
)
boss_h = scad.var(
name="boss_h",
default=5.0,
comment="raised center boss height",
)
bore_r = scad.var(name="bore_r", default=5.0, comment="through bore radius")
mount_r = scad.var(
name="mount_r",
default=3.0,
comment="mounting hole radius",
)
margin_x = scad.var(
name="mount_margin_x",
default=12.0,
comment="mounting hole x margin",
)
margin_y = scad.var(
name="mount_margin_y",
default=9.0,
comment="mounting hole y margin",
)
slot_w = scad.var(name="slot_w", default=34.0, comment="service slot width")
slot_h = scad.var(name="slot_h", default=8.0, comment="service slot height")
slot_y = scad.var(
name="slot_center_y",
default=16.0,
comment="service slot center y",
)
diamond_w = scad.var(
name="guided_diamond_w",
default=14.0,
comment="guided diamond pocket width",
)
diamond_h = scad.var(
name="guided_diamond_h",
default=8.0,
comment="guided diamond pocket height",
)
diamond_guide_gap = scad.var(
name="guided_diamond_guide_gap",
default=5.0,
comment="parallel guide rail offset",
)
relief_r = scad.var(
name="curve_relief_r",
default=4.0,
comment="curve-guided relief radius",
)
relief_guide_span = scad.var(
name="curve_relief_guide_span",
default=9.0,
comment="curve relief construction rail half span",
)
center_x = plate_w / 2.0
center_y = plate_h / 2.0
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")
plate_profile = make_rect_profile(
name="plate_outline",
x0=0.0,
y0=0.0,
width=plate_w,
height=plate_h,
)
plate_profile = scad.apply_tag(
shape=plate_profile,
tag="demo.profile.plate",
)
plate = scad.extrude_rsolid(
profile=plate_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t,
tag_prefix="constrained_sketch.plate",
result_tag="part.constrained_sketch.base_plate",
)
plate = scad.apply_tag(shape=plate, tag="demo.body.base_plate")
boss_profile = make_circle_profile(
"center_boss",
center_x,
center_y,
boss_r,
"boss_outer",
name="center_boss",
center_x=center_x,
center_y=center_y,
radius=boss_r,
circle_id="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")
boss = scad.extrude_rsolid(
profile=boss_profile,
direction=(0.0, 0.0, 1.0),
distance=boss_h + boss_overlap,
tag_prefix="constrained_sketch.boss",
result_tag="part.constrained_sketch.raised_boss",
)
boss = scad.translate_shape(
shape=boss,
vector=(0.0, 0.0, plate_t - boss_overlap),
)
boss = scad.apply_tag(shape=boss, tag="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",
name="center_bore",
center_x=center_x,
center_y=center_y,
radius=bore_r,
circle_id="bore",
)
bore_cutter = scad.extrude_rsolid(
bore_profile,
(0.0, 0.0, 1.0),
plate_t + boss_h + 2.0,
profile=bore_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t + boss_h + 2.0,
tag_prefix="constrained_sketch.bore.cutter",
result_tag="tool.constrained_sketch.center_bore",
)
bore_cutter = scad.translate_shape(
shape=bore_cutter,
vector=(0.0, 0.0, -1.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,
name="service_slot",
x0=center_x - slot_w / 2.0,
y0=slot_y - slot_h / 2.0,
width=slot_w,
height=slot_h,
)
slot_cutter = scad.extrude_rsolid(
profile=slot_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t + 2.0,
tag_prefix="constrained_sketch.slot.cutter",
result_tag="tool.constrained_sketch.service_slot",
)
slot_cutter = scad.translate_shape(
shape=slot_cutter,
vector=(0.0, 0.0, -1.0),
)
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,
name="guided_diamond_pocket",
center_x=plate_w - 24.0,
center_y=plate_h - 18.0,
width=diamond_w,
height=diamond_h,
guide_gap=diamond_guide_gap,
)
diamond_cutter = scad.extrude_rsolid(
profile=diamond_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t + 2.0,
tag_prefix="constrained_sketch.diamond.cutter",
result_tag="tool.constrained_sketch.diamond_pocket",
)
diamond_cutter = scad.translate_shape(
shape=diamond_cutter,
vector=(0.0, 0.0, -1.0),
)
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,
name="curve_guided_relief",
center_x=plate_w / 3.0,
center_y=plate_h - 12.0,
radius=relief_r,
guide_span=relief_guide_span,
)
curve_relief_cutter = scad.extrude_rsolid(
profile=curve_relief_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t + 2.0,
tag_prefix="constrained_sketch.curve_relief.cutter",
result_tag="tool.constrained_sketch.curve_relief",
)
curve_relief_cutter = scad.translate_shape(
shape=curve_relief_cutter,
vector=(0.0, 0.0, -1.0),
)
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),
@@ -285,13 +569,27 @@ with scad.GraphSession() as session:
]
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_tag = name.replace("_", ".")
mount_profile = make_circle_profile(
name=name,
center_x=x_pos,
center_y=y_pos,
radius=mount_r,
circle_id="mount_hole",
)
mount_cutter = scad.extrude_rsolid(
profile=mount_profile,
direction=(0.0, 0.0, 1.0),
distance=plate_t + 2.0,
tag_prefix=f"constrained_sketch.{mount_tag}.cutter",
result_tag=f"tool.constrained_sketch.{mount_tag}",
)
mount_cutters.append(
scad.translate_shape(
shape=mount_cutter,
vector=(0.0, 0.0, -1.0),
)
)
mount_cutters.append(scad.translate_shape(mount_cutter, (0.0, 0.0, -1.0)))
part = scad.cut_rsolid(
body,
@@ -302,96 +600,126 @@ with scad.GraphSession() as session:
mount_cutters,
skip_non_intersecting=False,
)
part = scad.apply_tag(part, "demo.constrained_sketch_bracket")
part = scad.apply_tag(
shape=part,
tag="demo.constrained_sketch_bracket",
)
scad.capture_result(value=part)
return {
"part": part,
"plate_profile": plate_profile,
"diamond_profile": diamond_profile,
"curve_relief_profile": curve_relief_profile,
}
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))
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
result = build_model()
data = result.value
MODEL_JSON_PATH.write_text(result.model_json, encoding="utf-8")
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,
)
rebuilt = result.replay()
scad.export_step(shapes=rebuilt, filename=str(STEP_PATH))
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.")
)
freecad_cmd = str(FREECAD_CMD) if FREECAD_CMD.exists() else None
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=result.model_json,
output_path=str(FCSTD_PATH),
document_name="SimpleCADConstrainedSketchDemo",
freecad_cmd=freecad_cmd,
)
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)
payload = json.loads(result.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(items=data["plate_profile"].get_edges())
.where(scad.ql.tag(pattern="sketch_entity.*"))
.all()
for tag in scad.list_tags(shape=edge)
if tag.startswith("sketch_entity.")
)
diamond_entity_tags = sorted(
tag
for edge in scad.ql.select(items=data["diamond_profile"].get_edges())
.where(scad.ql.tag(pattern="sketch_entity.*"))
.all()
for tag in scad.list_tags(shape=edge)
if tag.startswith("sketch_entity.")
)
curve_entity_tags = sorted(
tag
for edge in scad.ql.select(items=data["curve_relief_profile"].get_edges())
.where(scad.ql.tag(pattern="sketch_entity.*"))
.all()
for tag in scad.list_tags(shape=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(data["part"].get_volume(), 3))
print("wrote", MODEL_JSON_PATH)
print("wrote", STEP_PATH)
print("wrote", FCSTD_PATH)
if __name__ == "__main__":
main()
@@ -5,6 +5,7 @@ Run from the repository root with:
Generated files:
examples/out/naca0016_blade/naca0016_blade.model.json
examples/out/naca0016_blade/naca0016_blade.session.json
examples/out/naca0016_blade/naca0016_blade.step
examples/out/naca0016_blade/naca0016_blade.fcstd
@@ -27,26 +28,38 @@ 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:
@scad.model(graph_id="naca0016_blade")
def build_blade() -> scad.ModelResult:
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(shape=blade, tag="role.naca0016.blade")
blade = scad.apply_tag(shape=blade, tag="part.naca0016.blade")
scad.capture_result(value=blade)
return blade
def write_blade_artifacts(
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"
session_json_path = output_dir / "naca0016_blade.session.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))
result = build_blade()
blade = result.value
payload = json.loads(result.model_json)
model_json_path.write_text(result.model_json, encoding="utf-8")
session_json_path.write_text(result.session_json, encoding="utf-8")
scad.export_step(shapes=blade, filename=str(step_path))
bspline_nodes = [
node for node in payload["graph"]["nodes"] if node.get("op") == "make_spline_redge"
@@ -63,12 +76,13 @@ def build_blade(output_dir: Path, *, freecad_cmd: Path | None = DEFAULT_FREECAD_
print("bspline_knot_counts", knot_counts[:6])
print("volume", round(blade.get_volume(), 6))
print("wrote", model_json_path)
print("wrote", session_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),
json_str=result.model_json,
output_path=str(fcstd_path),
document_name="NACA0016Blade",
freecad_cmd=str(freecad_cmd),
)
@@ -89,7 +103,7 @@ def main() -> None:
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)
write_blade_artifacts(args.output_dir, freecad_cmd=args.freecad_cmd)
if __name__ == "__main__":
+320 -97
View File
@@ -9,53 +9,173 @@ import simplecadapi as scad
from simplecadapi import ql
OUT_DIR = Path("examples/out/hydraulic_rod_assembly")
OUT_DIR = Path(__file__).resolve().parent / "out" / "hydraulic_rod_assembly"
@scad.model(graph_id="hydraulic_rod_assembly", export_dir=OUT_DIR)
def build_hydraulic_rod_assembly():
flange_holes = [
(0.0, 16.0),
(16.0, 0.0),
(0.0, -16.0),
(-16.0, 0.0),
("zplus", 0.0, 16.0),
("yplus", 16.0, 0.0),
("zminus", 0.0, -16.0),
("yminus", -16.0, 0.0),
]
with scad.GraphSession() as session:
barrel = scad.make_cylinder_rsolid(
@scad.requires_session
def _build_in_session():
def _named_cylinder(
*,
radius: float,
height: float,
bottom_face_center: tuple[float, float, float],
axis: tuple[float, float, float],
start_face_tag: str,
end_face_tag: str,
side_faces_tag: str,
tag_prefix: str,
result_tag: str,
):
return scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=bottom_face_center,
axis=axis,
tag_prefix=tag_prefix,
result_tag=result_tag,
start_face_tag=start_face_tag,
end_face_tag=end_face_tag,
side_face_tag=side_faces_tag,
)
def _named_box(
*,
width: float,
height: float,
depth: float,
bottom_face_center: tuple[float, float, float],
bottom_face_tag: str,
top_face_tag: str,
side_faces_tag: str,
tag_prefix: str,
result_tag: str,
):
profile = scad.make_rectangle_rface(
# Rectangle profile axes are Y/X for a +Z normal; swap the
# dimensions to preserve make_box_rsolid's global X/Y layout.
width=height,
height=width,
center=bottom_face_center,
tag_prefix=f"{tag_prefix}.profile",
edge_tags=("bottom", "right", "top", "left"),
)
return scad.extrude_rsolid(
profile=profile,
direction=(0.0, 0.0, 1.0),
distance=depth,
tag_prefix=tag_prefix,
result_tag=result_tag,
start_face_tag=bottom_face_tag,
end_face_tag=top_face_tag,
side_faces_tag=side_faces_tag,
)
def _require_complete_face_naming(solid, prefix: str) -> None:
unnamed = [
index
for index, face in enumerate(solid.get_faces())
if not any(
tag.startswith(prefix)
for tag in scad.list_tags(face, scope="local")
)
]
if unnamed:
raise RuntimeError(
f"{prefix} final face naming is incomplete at indices {unnamed}"
)
def _require_shared_edge(solid, first_face_tag: str, second_face_tag: str):
first = ql.faces().where(ql.tag(first_face_tag)).exactly(1)
second = ql.faces().where(ql.tag(second_face_tag)).exactly(1)
shared = first.shared_boundary(second).incident_face_count(exactly=2)
incident = (
ql.edges()
.incident_to(first, second, distinct=True)
.incident_face_count(exactly=2)
)
shared_edge = shared.exactly(1).resolve(solid)[0]
incident_edge = incident.exactly(1).resolve(solid)[0]
if shared_edge.topo_id != incident_edge.topo_id:
raise RuntimeError(
f"Face pair {first_face_tag!r}, {second_face_tag!r} "
"did not resolve the same shared Edge"
)
return shared_edge
barrel = _named_cylinder(
radius=16.0,
height=120.0,
bottom_face_center=(-60.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="sleeve.barrel.face.rear",
end_face_tag="sleeve.barrel.face.front",
side_faces_tag="sleeve.barrel.face.outer",
tag_prefix="hydraulic.sleeve.barrel",
result_tag="part.hydraulic.sleeve.barrel",
)
rod_gland_flange = scad.make_cylinder_rsolid(
rod_gland_flange = _named_cylinder(
radius=22.0,
height=12.0,
bottom_face_center=(50.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="sleeve.gland.face.shoulder",
end_face_tag="sleeve.gland.face.mount",
side_faces_tag="sleeve.gland.face.outer",
tag_prefix="hydraulic.sleeve.gland.flange",
result_tag="part.hydraulic.sleeve.gland.flange",
)
rod_gland_nose = scad.make_cylinder_rsolid(
rod_gland_nose = _named_cylinder(
radius=13.0,
height=10.0,
bottom_face_center=(58.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="sleeve.gland.nose.face.rear",
end_face_tag="sleeve.gland.nose.face.front",
side_faces_tag="sleeve.gland.nose.face.outer",
tag_prefix="hydraulic.sleeve.gland.nose",
result_tag="part.hydraulic.sleeve.gland.nose",
)
base_cap = scad.make_cylinder_rsolid(
base_cap = _named_cylinder(
radius=18.0,
height=12.0,
bottom_face_center=(-66.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="sleeve.base.cap.face.rear",
end_face_tag="sleeve.base.cap.face.front",
side_faces_tag="sleeve.base.cap.face.outer",
tag_prefix="hydraulic.sleeve.base.cap",
result_tag="part.hydraulic.sleeve.base.cap",
)
rear_eye = scad.make_cylinder_rsolid(
rear_eye = _named_cylinder(
radius=14.0,
height=12.0,
bottom_face_center=(-80.0, -6.0, 0.0),
axis=(0.0, 1.0, 0.0),
start_face_tag="sleeve.rear.eye.face.ymin",
end_face_tag="sleeve.rear.eye.face.ymax",
side_faces_tag="sleeve.rear.eye.face.outer",
tag_prefix="hydraulic.sleeve.rear.eye",
result_tag="part.hydraulic.sleeve.rear.eye",
)
rear_eye_neck = scad.make_box_rsolid(
18.0,
14.0,
16.0,
rear_eye_neck = _named_box(
width=18.0,
height=14.0,
depth=16.0,
bottom_face_center=(-68.0, 0.0, -8.0),
bottom_face_tag="sleeve.rear.neck.face.bottom",
top_face_tag="sleeve.rear.neck.face.top",
side_faces_tag="sleeve.rear.neck.face.side",
tag_prefix="hydraulic.sleeve.rear.neck",
result_tag="part.hydraulic.sleeve.rear.neck",
)
sleeve_raw = scad.union_rsolid(
barrel,
@@ -66,70 +186,112 @@ def build_hydraulic_rod_assembly():
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),
barrel_bore = _named_cylinder(
radius=10.5,
height=136.0,
bottom_face_center=(-68.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="sleeve.barrel.bore.face.rear",
end_face_tag="sleeve.barrel.bore.face.front",
side_faces_tag="sleeve.barrel.bore.face.wall",
tag_prefix="hydraulic.sleeve.barrel.bore",
result_tag="tool.hydraulic.sleeve.barrel.bore",
)
sleeve_solid = scad.cut_rsolid(sleeve_raw, barrel_bore)
rear_eye_pin_bore = _named_cylinder(
radius=4.6,
height=26.0,
bottom_face_center=(-80.0, -13.0, 0.0),
axis=(0.0, 1.0, 0.0),
start_face_tag="sleeve.rear.eye.pin.face.ymin",
end_face_tag="sleeve.rear.eye.pin.face.ymax",
side_faces_tag="sleeve.rear.eye.pin.face.wall",
tag_prefix="hydraulic.sleeve.rear.eye.pin.bore",
result_tag="tool.hydraulic.sleeve.rear.eye.pin_bore",
)
sleeve_solid = scad.cut_rsolid(sleeve_solid, rear_eye_pin_bore)
for hole_id, y, z in flange_holes:
bolt_hole = _named_cylinder(
radius=1.8,
height=16.0,
bottom_face_center=(48.0, y, z),
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:
start_face_tag=f"sleeve.gland.bolt.{hole_id}.face.rear",
end_face_tag=f"sleeve.gland.bolt.{hole_id}.face.front",
side_faces_tag=f"sleeve.gland.bolt.{hole_id}.face.wall",
tag_prefix=f"hydraulic.sleeve.gland.bolt.{hole_id}",
result_tag=f"tool.hydraulic.sleeve.gland.bolt.{hole_id}",
)
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),
),
bolt_hole,
)
piston_land_left = scad.make_cylinder_rsolid(
piston_land_left = _named_cylinder(
radius=10.0,
height=3.2,
bottom_face_center=(-6.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="rod.piston.land.left.face.rear",
end_face_tag="rod.piston.land.left.face.front",
side_faces_tag="rod.piston.land.left.face.outer",
tag_prefix="hydraulic.rod.piston.land.left",
result_tag="part.hydraulic.rod.piston.land.left",
)
piston_seal_groove = scad.make_cylinder_rsolid(
piston_seal_groove = _named_cylinder(
radius=9.0,
height=6.0,
bottom_face_center=(-3.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="rod.piston.groove.face.rear",
end_face_tag="rod.piston.groove.face.front",
side_faces_tag="rod.piston.groove.face.outer",
tag_prefix="hydraulic.rod.piston.groove",
result_tag="part.hydraulic.rod.piston.groove",
)
piston_land_right = scad.make_cylinder_rsolid(
piston_land_right = _named_cylinder(
radius=10.0,
height=3.2,
bottom_face_center=(2.6, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="rod.piston.land.right.face.rear",
end_face_tag="rod.piston.land.right.face.front",
side_faces_tag="rod.piston.land.right.face.outer",
tag_prefix="hydraulic.rod.piston.land.right",
result_tag="part.hydraulic.rod.piston.land.right",
)
chrome_rod = scad.make_cylinder_rsolid(
chrome_rod = _named_cylinder(
radius=6.5,
height=132.0,
bottom_face_center=(3.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
start_face_tag="rod.shaft.face.piston",
end_face_tag="rod.shaft.face.eye",
side_faces_tag="rod.shaft.face.outer",
tag_prefix="hydraulic.rod.shaft",
result_tag="part.hydraulic.rod.shaft",
)
rod_eye = scad.make_cylinder_rsolid(
rod_eye = _named_cylinder(
radius=13.0,
height=9.0,
bottom_face_center=(143.0, -4.5, 0.0),
axis=(0.0, 1.0, 0.0),
start_face_tag="rod.eye.face.ymin",
end_face_tag="rod.eye.face.ymax",
side_faces_tag="rod.eye.face.outer",
tag_prefix="hydraulic.rod.eye",
result_tag="part.hydraulic.rod.eye",
)
rod_eye_neck = scad.make_box_rsolid(
20.0,
8.0,
13.0,
rod_eye_neck = _named_box(
width=20.0,
height=8.0,
depth=13.0,
bottom_face_center=(130.0, 0.0, -6.5),
bottom_face_tag="rod.eye.neck.face.bottom",
top_face_tag="rod.eye.neck.face.top",
side_faces_tag="rod.eye.neck.face.side",
tag_prefix="hydraulic.rod.eye.neck",
result_tag="part.hydraulic.rod.eye.neck",
)
piston_rod_raw = scad.union_rsolid(
piston_land_left,
@@ -140,23 +302,56 @@ def build_hydraulic_rod_assembly():
rod_eye_neck,
glue=False,
)
rod_eye_pin_hole = scad.make_cylinder_rsolid(
rod_eye_pin_hole = _named_cylinder(
radius=5.5,
height=13.0,
bottom_face_center=(143.0, -6.5, 0.0),
axis=(0.0, 1.0, 0.0),
start_face_tag="rod.eye.pin.face.ymin",
end_face_tag="rod.eye.pin.face.ymax",
side_faces_tag="rod.eye.pin.face.wall",
tag_prefix="hydraulic.rod.eye.pin.bore",
result_tag="tool.hydraulic.rod.eye.pin_bore",
)
piston_rod_solid = scad.cut_rsolid(piston_rod_raw, rod_eye_pin_hole)
sleeve_solid = scad.apply_tag(
shape=sleeve_solid,
tag="part.hydraulic.sleeve.finished",
)
piston_rod_solid = scad.apply_tag(
shape=piston_rod_solid,
tag="part.hydraulic.rod.finished",
)
_require_complete_face_naming(sleeve_solid, "sleeve.")
_require_complete_face_naming(piston_rod_solid, "rod.")
_require_shared_edge(
sleeve_solid,
"sleeve.gland.face.mount",
"sleeve.gland.face.outer",
)
_require_shared_edge(
sleeve_solid,
"sleeve.gland.nose.face.front",
"sleeve.barrel.bore.face.wall",
)
for hole_id in ("zplus", "yplus"):
_require_shared_edge(
sleeve_solid,
"sleeve.gland.face.mount",
f"sleeve.gland.bolt.{hole_id}.face.wall",
)
black_oxide_steel = scad.make_material_rmaterial(
"black_oxide_steel",
material_id="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",
material_id="chrome_plated_steel",
name="Chrome plated steel",
density=7.85e-6,
density_unit="kg/mm^3",
@@ -164,89 +359,117 @@ def build_hydraulic_rod_assembly():
)
sleeve_part = scad.make_part_rpart(
"outer_sleeve", sleeve_solid, name="Outer sleeve with clevis and gland"
part_id="outer_sleeve",
body=sleeve_solid,
name="Outer sleeve with clevis and gland",
)
sleeve_part = scad.assign_material_rpart(
part=sleeve_part, material=black_oxide_steel
)
sleeve_end_face = (
ql.faces()
.where(ql.tag("sleeve.gland.face.mount"))
.exactly(1)
.resolve(sleeve_solid)[0]
)
sleeve_connector = scad.make_face_connector_rconnector(
connector_id="slide_axis", face=sleeve_end_face
)
sleeve_part = scad.add_connector_rpart(
part=sleeve_part, connector=sleeve_connector
)
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"
part_id="piston_rod",
body=piston_rod_solid,
name="Inner piston rod with eye end",
)
piston_rod_part = scad.assign_material_rpart(
part=piston_rod_part, material=chrome_steel
)
rod_end_face = (
ql.faces()
.where(ql.tag("rod.piston.land.left.face.rear"))
.exactly(1)
.resolve(piston_rod_solid)[0]
)
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)
rod_connector = scad.make_face_connector_rconnector(
connector_id="slide_axis", face=rod_end_face, flip=rod_flip
)
piston_rod_part = scad.add_connector_rpart(
part=piston_rod_part, connector=rod_connector
)
hydraulic_assembly = scad.make_assembly_rassembly(
"hydraulic_rod_assembly", name="Hydraulic rod assembly"
assembly_id="hydraulic_rod_assembly", name="Hydraulic rod assembly"
)
hydraulic_assembly = scad.add_component_rassembly(
hydraulic_assembly,
sleeve_part,
assembly=hydraulic_assembly,
item=sleeve_part,
component_id="outer_sleeve",
placement=scad.identity_placement_rplacement(),
)
hydraulic_assembly = scad.add_component_rassembly(
hydraulic_assembly,
piston_rod_part,
assembly=hydraulic_assembly,
item=piston_rod_part,
component_id="inner_piston_rod",
placement=scad.identity_placement_rplacement(),
)
hydraulic_assembly = scad.ground_component_rassembly(
hydraulic_assembly, "outer_sleeve"
assembly=hydraulic_assembly, component_id="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"),
assembly=hydraulic_assembly,
constraint_id="rod_slide",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="outer_sleeve", connector_id="slide_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="inner_piston_rod", connector_id="slide_axis"
),
drive_distance=0.0,
distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 100.0),
distance_limit=scad.make_scalar_limit_rscalarlimit(
lower_value=0.0, upper_value=100.0
),
)
hydraulic_assembly = scad.solve_assembly_constraints_rassembly(
hydraulic_assembly
assembly=hydraulic_assembly
)
preview = scad.make_compound_from_assembly_rcompound(hydraulic_assembly)
model_json = scad.export_model_json(session)
preview = scad.make_compound_from_assembly_rcompound(
assembly=hydraulic_assembly
)
preview = scad.apply_tag(
shape=preview,
tag="assembly.hydraulic.preview",
)
return hydraulic_assembly, preview
return hydraulic_assembly, preview, model_json
hydraulic_assembly, preview = _build_in_session()
scad.capture_result(value=(hydraulic_assembly, preview))
return hydraulic_assembly, preview
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
assembly, preview, model_json = build_hydraulic_rod_assembly()
result = build_hydraulic_rod_assembly()
assembly, preview = result.value
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()))
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=result.model_json,
output_path=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)
payload = json.loads(result.model_json)
face_count = len(ql.faces().resolve(preview))
print("assembly", assembly.assembly_id)
print("components", assembly.component_ids())
@@ -254,8 +477,8 @@ def main() -> None:
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)
for path in result.artifact_paths.values():
print("wrote", path)
print("fcstd", fcstd_status)
-119
View File
@@ -1,119 +0,0 @@
"""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()
@@ -1,636 +0,0 @@
"""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()
@@ -1,847 +0,0 @@
"""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()
-168
View File
@@ -1,168 +0,0 @@
"""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()
@@ -1,101 +0,0 @@
"""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()
@@ -4,37 +4,68 @@ 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
if __package__:
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
else:
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
@scad.requires_session
def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly:
"""Build the full 20:1 compact reducer assembly and solve constraints."""
@@ -84,6 +115,7 @@ def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly:
bearing = make_radial_ball_bearing_rassembly(
bearing_id="micro_radial_ball_bearing",
spec=UNIVERSAL_RADIAL_BEARING,
tag_prefix="reducer.bearing.micro.radial",
)
reducer = scad.make_assembly_rassembly(
@@ -133,6 +165,7 @@ def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly:
return reducer
@scad.requires_session
def _add_fixed_components_rassembly(
*,
assembly: scad.Assembly,
@@ -150,6 +183,7 @@ def _add_fixed_components_rassembly(
return assembly
@scad.requires_session
def _add_bearing_components_rassembly(
*,
assembly: scad.Assembly,
@@ -211,6 +245,7 @@ def _add_bearing_components_rassembly(
return assembly
@scad.requires_session
def _add_public_interface_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
"""Expose stable actuator module datums without leaking private component ids."""
@@ -231,6 +266,7 @@ def _add_public_interface_connectors_rassembly(*, assembly: scad.Assembly) -> sc
return assembly
@scad.requires_session
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")
@@ -290,6 +326,7 @@ def _add_reducer_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assem
return assembly
@scad.requires_session
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"),
@@ -338,6 +375,7 @@ def _add_bearing_interface_constraints_rassembly(*, assembly: scad.Assembly) ->
return assembly
@scad.requires_session
def _add_stage_mesh_constraints_rassembly(
*,
assembly: scad.Assembly,
@@ -388,6 +426,7 @@ def _add_stage_mesh_constraints_rassembly(
return assembly
@scad.requires_session
def _gear_stage_rplacement(*, stage: StageSpec) -> scad.Placement:
return scad.make_placement_rplacement(
origin=(0.0, 0.0, stage.bottom_z),
@@ -396,6 +435,7 @@ def _gear_stage_rplacement(*, stage: StageSpec) -> scad.Placement:
)
@scad.requires_session
def _ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
@@ -10,10 +10,12 @@ from common import make_z_rotation_rplacement
from dimensions import BearingSpec, PLANET_COUNT, StageSpec
@scad.requires_session
def make_radial_ball_bearing_rassembly(
*,
bearing_id: str,
spec: BearingSpec,
tag_prefix: str,
) -> scad.Assembly:
"""Create a standard radial ball bearing assembly for reducer placement."""
@@ -30,7 +32,11 @@ def make_radial_ball_bearing_rassembly(
)
meta = bearing.get_metadata("std.bearing.ball_bearing")
outer_ring = bearing.get_component("outer_ring").item.body
outer_ring = scad.apply_tag(shape=outer_ring, tag=f"solid.{tag_prefix}.outer.ring")
inner_ring = bearing.get_component("inner_ring").item.body
inner_ring = scad.apply_tag(shape=inner_ring, tag=f"solid.{tag_prefix}.inner.ring")
rolling_element = bearing.get_component(meta["ball_component_ids"][0]).item.body
scad.apply_tag(shape=rolling_element, tag=f"solid.{tag_prefix}.rolling.element")
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}"
@@ -42,12 +48,14 @@ def make_radial_ball_bearing_rassembly(
return bearing
@scad.requires_session
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)
@scad.requires_session
def make_planet_bearing_rplacements(*, stage: StageSpec) -> list[scad.Placement]:
"""Return placed bearing placements centered in all planets of one stage."""
@@ -35,6 +35,7 @@ from dimensions import (
)
@scad.requires_session
def make_stage_carrier_rpart(
*,
stage: StageSpec,
@@ -55,6 +56,7 @@ def make_stage_carrier_rpart(
pad_radius=STAGE1_PAD_RADIUS,
central_shaft_radius=STAGE1_CARRIER_SHAFT_RADIUS,
central_shaft_top_z=STAGE_2.top_z,
tag_prefix="reducer.stage1.carrier",
)
connector_specs = [
{
@@ -83,6 +85,7 @@ def make_stage_carrier_rpart(
pad_radius=STAGE2_PAD_RADIUS,
central_shaft_radius=OUTPUT_SHAFT_RADIUS,
central_shaft_top_z=OUTPUT_FLANGE_TOP_Z,
tag_prefix="reducer.stage2.carrier",
)
connector_specs = [
{
@@ -142,6 +145,7 @@ def make_stage_carrier_rpart(
return part
@scad.requires_session
def _make_carrier_solid_rsolid(
*,
stage: StageSpec,
@@ -155,12 +159,15 @@ def _make_carrier_solid_rsolid(
pad_radius: float,
central_shaft_radius: float,
central_shaft_top_z: float,
tag_prefix: str,
) -> 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),
tag_prefix=f"{tag_prefix}.hub",
result_tag=f"solid.{tag_prefix}.hub",
)
solids = [hub]
@@ -171,6 +178,8 @@ def _make_carrier_solid_rsolid(
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),
tag_prefix=f"{tag_prefix}.shaft",
result_tag=f"solid.{tag_prefix}.shaft",
)
)
@@ -195,6 +204,8 @@ def _make_carrier_solid_rsolid(
height=arm_width,
depth=plate_thickness,
bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z),
tag_prefix=f"{tag_prefix}.arm.i{index + 1}",
result_tag=f"solid.{tag_prefix}.arm.i{index + 1}",
)
if abs(carrier_angle) > 1.0e-9:
arm = scad.rotate_shape(
@@ -210,6 +221,8 @@ def _make_carrier_solid_rsolid(
height=plate_thickness,
bottom_face_center=(center_xy[0], center_xy[1], plate_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.pad.i{index + 1}",
result_tag=f"solid.{tag_prefix}.pad.i{index + 1}",
)
)
solids.append(
@@ -218,6 +231,8 @@ def _make_carrier_solid_rsolid(
height=pin_height,
bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.pin.i{index + 1}",
result_tag=f"solid.{tag_prefix}.pin.i{index + 1}",
)
)
solids.append(
@@ -226,6 +241,8 @@ def _make_carrier_solid_rsolid(
height=pin_land_height,
bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.pin.land.i{index + 1}",
result_tag=f"solid.{tag_prefix}.pin.land.i{index + 1}",
)
)
@@ -18,7 +18,10 @@ import time
import simplecadapi as scad
from assembly import make_two_stage_planetary_reducer_rassembly
if __package__:
from .main import _build_compact_two_stage_planetary_reducer
else:
from main import _build_compact_two_stage_planetary_reducer
sys.setrecursionlimit(30000)
@@ -28,8 +31,8 @@ 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()
result = _build_compact_two_stage_planetary_reducer()
assembly, _preview = result.value
elapsed = time.perf_counter() - start
return assembly, len(log_buffer.getvalue().splitlines()), elapsed
@@ -9,6 +9,7 @@ import simplecadapi as scad
from simplecadapi import ql
@scad.requires_session
def make_z_rotation_rplacement(
*,
origin: tuple[float, float, float],
@@ -26,12 +27,14 @@ def make_z_rotation_rplacement(
)
@scad.requires_session
def make_annular_cylinder_rsolid(
*,
outer_radius: float,
inner_radius: float,
height: float,
bottom_z: float,
tag_prefix: str,
tag: str,
) -> scad.Solid:
"""Create a single hollow cylindrical solid with a through bore."""
@@ -43,12 +46,16 @@ def make_annular_cylinder_rsolid(
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.outer",
result_tag=f"solid.{tag_prefix}.outer",
)
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),
tag_prefix=f"{tag_prefix}.bore",
result_tag=f"solid.{tag_prefix}.bore.cutter",
)
annular = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
annular = scad.apply_tag(shape=annular, tag=tag)
@@ -56,6 +63,7 @@ def make_annular_cylinder_rsolid(
return annular
@scad.requires_session
def make_axis_connector_rconnector(
*,
connector_id: str,
@@ -83,6 +91,7 @@ def make_axis_connector_rconnector(
)
@scad.requires_session
def make_axis_part_rpart(
*,
part_id: str,
@@ -113,6 +122,7 @@ def make_axis_part_rpart(
return part
@scad.requires_session
def add_placement_axis_connector_rpart(
*,
part: scad.Part,
@@ -130,6 +140,7 @@ def add_placement_axis_connector_rpart(
return scad.add_connector_rpart(part=part, connector=connector)
@scad.requires_session
def _apply_tags(shape: scad.Solid, tags: Iterable[str]) -> scad.Solid:
"""Apply normalized tags through the public SimpleCAD tag API."""
@@ -172,9 +183,13 @@ def _axis_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()
local_roles = [
tag
for tag in scad.list_tags(shape=solid, scope="local")
if tag.startswith("role.")
]
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"{label}: faces={len(faces)} local_roles={len(local_roles)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
@@ -47,6 +47,7 @@ from dimensions import (
)
@scad.requires_session
def make_input_flange_rpart(*, material: scad.Material) -> scad.Part:
"""Create the reducer input flange part with six bolt holes."""
@@ -61,6 +62,7 @@ def make_input_flange_rpart(*, material: scad.Material) -> scad.Part:
hole_count=INPUT_FLANGE_HOLE_COUNT,
counterbore_diameter=INPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER,
counterbore_depth=INPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
tag_prefix="reducer.input.flange",
)
flange = scad.translate_shape(
shape=flange,
@@ -90,10 +92,11 @@ def make_input_flange_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
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 = _make_output_flange_solid_rsolid(tag_prefix="reducer.output.flange")
flange = scad.translate_shape(
shape=flange,
vector=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z),
@@ -123,7 +126,8 @@ def make_output_flange_rpart(*, material: scad.Material) -> scad.Part:
)
def _make_output_flange_solid_rsolid() -> scad.Solid:
@scad.requires_session
def _make_output_flange_solid_rsolid(*, tag_prefix: str) -> scad.Solid:
"""Build the sealed actuator-style output flange.
The earlier example used a small six-hole disk. That was enough to prove
@@ -139,12 +143,16 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
height=OUTPUT_FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.base",
result_tag=f"solid.{tag_prefix}.base",
)
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),
tag_prefix=f"{tag_prefix}.boss",
result_tag=f"solid.{tag_prefix}.boss",
)
register_outer = scad.make_cylinder_rsolid(
@@ -152,12 +160,16 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
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),
tag_prefix=f"{tag_prefix}.register.outer",
result_tag=f"solid.{tag_prefix}.register.outer",
)
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),
tag_prefix=f"{tag_prefix}.register.inner",
result_tag=f"solid.{tag_prefix}.register.inner.cutter",
)
register = scad.cut_rsolid(register_outer, register_inner, skip_non_intersecting=False)
@@ -173,6 +185,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
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),
tag_prefix=f"{tag_prefix}.center.bore",
result_tag=f"solid.{tag_prefix}.center.bore.cutter",
)
]
@@ -193,6 +207,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
0.0,
OUTPUT_FLANGE_THICKNESS - 0.25,
),
tag_prefix=f"{tag_prefix}.register.gap.i{index + 1}",
result_tag=f"solid.{tag_prefix}.register.gap.i{index + 1}.cutter",
)
cutters.append(
scad.rotate_shape(
@@ -211,7 +227,7 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
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:
for index, angle_degrees in enumerate(output_hole_angles):
angle = math.radians(angle_degrees)
x = output_bolt_radius * math.cos(angle)
y = output_bolt_radius * math.sin(angle)
@@ -225,6 +241,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_REGISTER_HEIGHT + 1.0,
bottom_face_center=(x, y, -0.5),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.link.hole.i{index + 1}",
result_tag=f"solid.{tag_prefix}.link.hole.i{index + 1}.cutter",
)
)
cutters.append(
@@ -239,6 +257,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
- OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.link.counterbore.i{index + 1}",
result_tag=f"solid.{tag_prefix}.link.counterbore.i{index + 1}.cutter",
)
)
@@ -257,6 +277,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT + 1.0,
bottom_face_center=(x, y, -0.5),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.cap.hole.i{index + 1}",
result_tag=f"solid.{tag_prefix}.cap.hole.i{index + 1}.cutter",
)
)
cutters.append(
@@ -271,6 +293,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
- OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.cap.counterbore.i{index + 1}",
result_tag=f"solid.{tag_prefix}.cap.counterbore.i{index + 1}.cutter",
)
)
@@ -288,6 +312,7 @@ def _make_output_flange_solid_rsolid() -> scad.Solid:
return flange
@scad.requires_session
def _make_n_hole_flange_solid_rsolid(
*,
flange_outer_diameter: float,
@@ -300,6 +325,7 @@ def _make_n_hole_flange_solid_rsolid(
hole_count: int,
counterbore_diameter: float | None = None,
counterbore_depth: float = 0.0,
tag_prefix: str,
) -> scad.Solid:
"""Build a flange without edge-pick features so FreeCAD export is stable."""
@@ -308,12 +334,16 @@ def _make_n_hole_flange_solid_rsolid(
height=flange_thickness,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.base",
result_tag=f"solid.{tag_prefix}.base",
)
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),
tag_prefix=f"{tag_prefix}.boss",
result_tag=f"solid.{tag_prefix}.boss",
)
flange = scad.union_rsolid([outer, boss], glue=False)
@@ -323,6 +353,8 @@ def _make_n_hole_flange_solid_rsolid(
height=flange_thickness + boss_height + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.center.bore",
result_tag=f"solid.{tag_prefix}.center.bore.cutter",
)
]
bolt_circle_radius = hole_circle_diameter / 2.0
@@ -338,6 +370,8 @@ def _make_n_hole_flange_solid_rsolid(
-1.0,
),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.mount.hole.i{index + 1}",
result_tag=f"solid.{tag_prefix}.mount.hole.i{index + 1}.cutter",
)
)
if counterbore_diameter is not None and counterbore_depth > 0.0:
@@ -354,6 +388,8 @@ def _make_n_hole_flange_solid_rsolid(
flange_thickness - counterbore_depth,
),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.mount.counterbore.i{index + 1}",
result_tag=f"solid.{tag_prefix}.mount.counterbore.i{index + 1}.cutter",
)
)
flange = scad.cut_rsolid(flange, cutters, skip_non_intersecting=False)
@@ -28,6 +28,7 @@ from dimensions import (
)
@scad.requires_session
def make_stage_ring_gear_rpart(
*,
stage: StageSpec,
@@ -46,25 +47,35 @@ def make_stage_ring_gear_rpart(
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
)
ring = scad.apply_tag(shape=ring, tag=f"solid.reducer.{stage.stage_id}.ring.gear")
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),
tag_prefix=f"reducer.{stage.stage_id}.ring.support.outer",
result_tag=f"solid.reducer.{stage.stage_id}.ring.support.outer",
)
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),
tag_prefix=f"reducer.{stage.stage_id}.ring.support.bore",
result_tag=f"solid.reducer.{stage.stage_id}.ring.support.bore.cutter",
)
support = scad.cut_rsolid(
support,
support_bore,
skip_non_intersecting=False,
tracking_policy=scad.TrackingPolicy.GRAPH,
)
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 = scad.union_rsolid(
[ring, support],
glue=False,
tracking_policy=scad.TrackingPolicy.GRAPH,
)
ring = _apply_tags(
ring,
tags=(f"role.{stage.stage_id}.fixed_ring_gear", "group.two_stage_reducer"),
@@ -91,6 +102,7 @@ def make_stage_ring_gear_rpart(
)
@scad.requires_session
def make_stage_sun_gear_rpart(
*,
stage: StageSpec,
@@ -109,10 +121,12 @@ def make_stage_sun_gear_rpart(
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
sun = scad.apply_tag(shape=sun, tag=f"solid.reducer.{stage.stage_id}.sun.gear")
sun = _cut_bore_rsolid(
label=f"{stage.stage_id}_sun_bore",
solid=sun,
bore_radius=bore_radius,
tag_prefix=f"reducer.{stage.stage_id}.sun.bore",
)
sun = _apply_tags(
sun,
@@ -139,6 +153,7 @@ def make_stage_sun_gear_rpart(
)
@scad.requires_session
def make_stage_planet_gear_rpart(
*,
stage: StageSpec,
@@ -157,11 +172,13 @@ def make_stage_planet_gear_rpart(
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
planet = scad.apply_tag(shape=planet, tag=f"solid.reducer.{stage.stage_id}.planet.gear")
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,
tag_prefix=f"reducer.{stage.stage_id}.planet.bearing.seat",
)
planet = _apply_tags(
planet,
@@ -194,6 +211,7 @@ def make_stage_planet_gear_rpart(
)
@scad.requires_session
def make_planet_component_rplacement(
*,
stage: StageSpec,
@@ -216,19 +234,28 @@ def make_planet_component_rplacement(
return make_z_rotation_rplacement(origin=center, angle_degrees=planet_spin)
@scad.requires_session
def _cut_bore_rsolid(
*,
label: str,
solid: scad.Solid,
bore_radius: float,
tag_prefix: str,
) -> 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),
tag_prefix=tag_prefix,
result_tag=f"solid.{tag_prefix}.cutter",
)
bored = scad.cut_rsolid(
solid,
cutter,
skip_non_intersecting=False,
tracking_policy=scad.TrackingPolicy.GRAPH,
)
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
@@ -48,6 +48,7 @@ from dimensions import (
)
@scad.requires_session
def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
"""Create the through-bolted housing sleeve with internal datum collars."""
@@ -56,6 +57,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=HOUSING_INNER_RADIUS,
height=HOUSING_HEIGHT,
bottom_z=HOUSING_BOTTOM_Z,
tag_prefix="reducer.housing.sleeve",
tag="role.housing_sleeve",
)
front_flange = _make_end_flange_rsolid(
@@ -63,14 +65,16 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=OUTPUT_SEAL_BORE_RADIUS,
thickness=HOUSING_FRONT_FLANGE_THICKNESS,
bottom_z=HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_FRONT_FLANGE_THICKNESS,
tag_prefix="reducer.housing.front.end.cap",
)
rear_flange = _make_end_flange_rsolid(
label="rear",
inner_radius=INPUT_SEAL_BORE_RADIUS,
thickness=HOUSING_REAR_FLANGE_THICKNESS,
bottom_z=HOUSING_BOTTOM_Z,
tag_prefix="reducer.housing.rear.end.cap",
)
mount_pad = _make_mount_sector_pad_rsolid()
mount_pad = _make_mount_sector_pad_rsolid(tag_prefix="reducer.housing.mount.sector.pad")
collars = []
datum_zs = (
INPUT_FLANGE_TOP_Z,
@@ -91,6 +95,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=HOUSING_DATUM_INNER_RADIUS,
height=0.36,
bottom_z=target_z - 0.36,
tag_prefix=f"reducer.housing.datum.collar.i{index + 1}",
tag=f"role.housing_axis_datum_{index + 1}",
)
collars.append(collar)
@@ -102,8 +107,8 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
housing = scad.cut_rsolid(
housing,
[
_make_mount_gap_cutters_rsolids(),
_make_mount_hole_cutters_rsolids(),
_make_mount_gap_cutters_rsolids(tag_prefix="reducer.housing.mount.gap"),
_make_mount_hole_cutters_rsolids(tag_prefix="reducer.housing.mount.hole"),
],
skip_non_intersecting=False,
)
@@ -155,12 +160,14 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
return part
@scad.requires_session
def _make_end_flange_rsolid(
*,
label: str,
inner_radius: float,
thickness: float,
bottom_z: float,
tag_prefix: str,
) -> scad.Solid:
"""Build one sealed housing end cap.
@@ -174,6 +181,7 @@ def _make_end_flange_rsolid(
inner_radius=inner_radius,
height=thickness,
bottom_z=bottom_z,
tag_prefix=tag_prefix,
tag=f"role.housing_{label}_sealed_end_cap",
)
clearance = OUTPUT_SEAL_RUNNING_CLEARANCE if label == "front" else INPUT_SEAL_RUNNING_CLEARANCE
@@ -183,7 +191,8 @@ def _make_end_flange_rsolid(
return flange
def _make_mount_sector_pad_rsolid() -> scad.Solid:
@scad.requires_session
def _make_mount_sector_pad_rsolid(*, tag_prefix: str) -> scad.Solid:
"""Build four graceful full-height sector pads before the global hole cut."""
outer = scad.make_cylinder_rsolid(
@@ -191,12 +200,16 @@ def _make_mount_sector_pad_rsolid() -> scad.Solid:
height=HOUSING_HEIGHT,
bottom_face_center=(0.0, 0.0, HOUSING_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.outer",
result_tag=f"solid.{tag_prefix}.outer",
)
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),
tag_prefix=f"{tag_prefix}.inner",
result_tag=f"solid.{tag_prefix}.inner.cutter",
)
pad = scad.cut_rsolid(outer, inner, skip_non_intersecting=False)
pad = _apply_tags(
@@ -211,7 +224,8 @@ def _make_mount_sector_pad_rsolid() -> scad.Solid:
return pad
def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]:
@scad.requires_session
def _make_mount_gap_cutters_rsolids(*, tag_prefix: str) -> list[scad.Solid]:
"""Build shallow radial gap cutters that divide the outer band into sectors."""
cutters = []
@@ -229,6 +243,8 @@ def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]:
height=HOUSING_MOUNT_SECTOR_GAP_WIDTH,
depth=HOUSING_HEIGHT + 2.0,
bottom_face_center=(gap_center_radius, 0.0, HOUSING_BOTTOM_Z - 1.0),
tag_prefix=f"{tag_prefix}.i{index + 1}",
result_tag=f"solid.{tag_prefix}.i{index + 1}.cutter",
)
cutters.append(
scad.rotate_shape(
@@ -245,7 +261,8 @@ def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]:
return cutters
def _make_mount_hole_cutters_rsolids() -> list[scad.Solid]:
@scad.requires_session
def _make_mount_hole_cutters_rsolids(*, tag_prefix: str) -> list[scad.Solid]:
"""Build one shared cutter set for the boss and housing body holes."""
cutters = []
@@ -262,11 +279,23 @@ def _make_mount_hole_cutters_rsolids() -> list[scad.Solid]:
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))
cutters.extend(
_make_single_mount_hole_cutters_rsolids(
angle=angle,
tag_prefix=(
f"{tag_prefix}.sector.i{sector_index + 1}.i{hole_index + 1}"
),
)
)
return cutters
def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]:
@scad.requires_session
def _make_single_mount_hole_cutters_rsolids(
*,
angle: float,
tag_prefix: str,
) -> list[scad.Solid]:
"""Build through and counterbore cutters for one housing screw."""
x = HOUSING_MOUNT_HOLE_CIRCLE_RADIUS * math.cos(angle)
@@ -282,6 +311,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]
height=HOUSING_HEIGHT + 2.0,
bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.through",
result_tag=f"solid.{tag_prefix}.through.cutter",
)
)
# Counterbores are added on both ends so the actuator can be mounted from
@@ -297,6 +328,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]
HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_MOUNT_COUNTERBORE_DEPTH,
),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.front.counterbore",
result_tag=f"solid.{tag_prefix}.front.counterbore.cutter",
)
)
cutters.append(
@@ -305,6 +338,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]
height=HOUSING_MOUNT_COUNTERBORE_DEPTH + 0.4,
bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 0.2),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.rear.counterbore",
result_tag=f"solid.{tag_prefix}.rear.counterbore.cutter",
)
)
return cutters
@@ -8,9 +8,34 @@ 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
if __package__:
import importlib
# The example also supports direct-file execution, so its child modules use
# sibling imports. Register those siblings before importing the package entry.
for _module_name in (
"dimensions",
"common",
"materials",
"bearings",
"carriers",
"flanges",
"gears",
"housing",
"shafts",
"assembly",
):
sys.modules.setdefault(
_module_name,
importlib.import_module(f"{__package__}.{_module_name}"),
)
from .assembly import make_two_stage_planetary_reducer_rassembly
from .common import _ground_compound
from .dimensions import HOUSING_HEIGHT, HOUSING_OUTER_RADIUS, TOTAL_REDUCTION
else:
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.
@@ -19,16 +44,16 @@ sys.setrecursionlimit(30000)
OUT_DIR = Path("examples/out/compact_two_stage_planetary_reducer")
@scad.model(graph_id="compact_two_stage_planetary_reducer")
def _build_compact_two_stage_planetary_reducer():
"""Build the reducer and return assembly, preview compound, and JSON exports."""
"""Build the reducer and return its assembly and preview compound."""
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
assembly = make_two_stage_planetary_reducer_rassembly()
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
preview = scad.apply_tag(shape=preview, tag="scene.reducer.preview")
_ground_compound(label="reducer_preview", compound=preview)
scad.capture_result(value=(assembly, preview))
return assembly, preview
def main() -> None:
@@ -42,19 +67,20 @@ def main() -> None:
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")
result = _build_compact_two_stage_planetary_reducer()
assembly, preview = result.value
model_path.write_text(result.model_json, encoding="utf-8")
session_path.write_text(result.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)
imported = scad.import_model_json(json_str=result.model_json)
replayed = scad.replay_model_json(json_str=result.model_json)
payload = json.loads(result.model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
json_str=result.model_json,
output_path=str(fcstd_path.resolve()),
document_name="CompactTwoStagePlanetaryReducer",
freecad_cmd=None,
@@ -5,6 +5,7 @@ from __future__ import annotations
import simplecadapi as scad
@scad.requires_session
def make_reducer_materials_rdict() -> dict[str, scad.Material]:
"""Create the small set of reusable material records for the assembly."""
@@ -8,6 +8,7 @@ from common import _apply_tags, add_placement_axis_connector_rpart, make_axis_pa
from dimensions import INPUT_BEARING_Z, INPUT_FLANGE_TOP_Z, INPUT_SHAFT_RADIUS, STAGE_1
@scad.requires_session
def make_input_shaft_rpart(*, material: scad.Material) -> scad.Part:
"""Create the input shaft linking the input flange and stage 1 sun."""
@@ -17,6 +18,8 @@ def make_input_shaft_rpart(*, material: scad.Material) -> scad.Part:
height=height,
bottom_face_center=(0.0, 0.0, INPUT_FLANGE_TOP_Z),
axis=(0.0, 0.0, 1.0),
tag_prefix="reducer.input.shaft",
result_tag="solid.reducer.input.shaft",
)
shaft = _apply_tags(
shaft,
@@ -1,75 +0,0 @@
"""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()
@@ -1,142 +0,0 @@
"""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
@@ -1,194 +0,0 @@
"""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",
),
),
)
@@ -1,151 +0,0 @@
"""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()
@@ -1,164 +0,0 @@
"""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"),),
)
@@ -1,254 +0,0 @@
"""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}"
)
@@ -1,343 +0,0 @@
"""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,
)
@@ -1,173 +0,0 @@
"""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}"
)
@@ -1,61 +0,0 @@
"""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),
),
}
@@ -1,592 +0,0 @@
"""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)
@@ -1,90 +0,0 @@
"""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()
@@ -1,163 +0,0 @@
"""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}"
)
@@ -1,78 +0,0 @@
"""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))}"
)
@@ -1,54 +0,0 @@
"""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),
)
@@ -1,71 +0,0 @@
"""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()
@@ -1,35 +0,0 @@
"""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
@@ -1,206 +0,0 @@
"""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)
@@ -88,6 +88,7 @@ except ImportError: # Support direct execution from this example directory.
from motor import make_bldc_rotor_rassembly, make_bldc_stator_rassembly
@scad.requires_session
def make_integrated_bldc_joint_actuator_rassembly(
*, materials: dict[str, scad.Material]
) -> scad.Assembly:
@@ -116,6 +117,7 @@ def make_integrated_bldc_joint_actuator_rassembly(
return actuator
@scad.requires_session
def make_integrated_bldc_joint_actuator_components_rtuple(
*, materials: dict[str, scad.Material]
) -> tuple[tuple[str, scad.Part | scad.Assembly, scad.Placement, str], ...]:
@@ -258,6 +260,7 @@ def make_integrated_bldc_joint_actuator_components_rtuple(
)
@scad.requires_session
def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
forwarded = (
("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"),
@@ -279,6 +282,7 @@ def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembl
return assembly
@scad.requires_session
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")
@@ -339,6 +343,7 @@ def _add_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
return assembly
@scad.requires_session
def _add_stage_constraints_rassembly(
*,
assembly: scad.Assembly,
@@ -386,6 +391,7 @@ def _add_stage_constraints_rassembly(
return assembly
@scad.requires_session
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"),
@@ -437,5 +443,6 @@ def _add_bearing_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assem
return assembly
@scad.requires_session
def _stage_rplacement(*, stage: StageSpec) -> scad.Placement:
return scad.make_placement_rplacement(origin=(0.0, 0.0, stage.bottom_z))
@@ -14,6 +14,7 @@ else:
from gears import planet_center_xy
@scad.requires_session
def make_standard_planet_bearing_rassembly(
*,
bearing_id: str,
@@ -34,6 +35,7 @@ def make_standard_planet_bearing_rassembly(
return bearing
@scad.requires_session
def make_main_bearing_rassembly(
*,
bearing_id: str,
@@ -48,11 +50,16 @@ def make_main_bearing_rassembly(
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
bearing_tag_prefix = ".".join(
f"size{token}" if token[0].isdigit() else token
for token in bearing_id.split("_")
)
inner_ring = make_annulus_rsolid(
outer_radius=inner_outer_radius,
inner_radius=bore_radius,
bottom_z=-spec.width / 2.0,
height=spec.width,
tag_prefix=f"bearing.{bearing_tag_prefix}.inner.ring",
tags=("role.bearing_inner_ring",),
)
outer_ring = make_annulus_rsolid(
@@ -60,12 +67,17 @@ def make_main_bearing_rassembly(
inner_radius=outer_inner_radius,
bottom_z=-spec.width / 2.0,
height=spec.width,
tag_prefix=f"bearing.{bearing_tag_prefix}.outer.ring",
tags=("role.bearing_outer_ring",),
)
ball = scad.make_sphere_rsolid(
radius=ball_radius,
center=(pitch_radius, 0.0, 0.0),
)
ball = scad.apply_tag(
shape=ball,
tag=f"solid.bearing.{bearing_tag_prefix}.rolling.element",
)
inner_part = make_axis_part_rpart(
part_id=f"{bearing_id}_inner_ring",
body=inner_ring,
@@ -148,12 +160,14 @@ def make_main_bearing_rassembly(
return bearing
@scad.requires_session
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)
@scad.requires_session
def make_planet_bearing_rplacement(
*,
stage: StageSpec,
@@ -9,6 +9,7 @@ import simplecadapi as scad
from simplecadapi import ql
@scad.requires_session
def apply_tags(*, shape: scad.Solid, tags: Iterable[str]) -> scad.Solid:
"""Apply semantic tags through the public functional API."""
@@ -18,12 +19,14 @@ def apply_tags(*, shape: scad.Solid, tags: Iterable[str]) -> scad.Solid:
return tagged
@scad.requires_session
def make_annulus_rsolid(
*,
outer_radius: float,
inner_radius: float,
bottom_z: float,
height: float,
tag_prefix: str,
tags: Iterable[str],
) -> scad.Solid:
"""Create a strict single-solid annular cylinder."""
@@ -33,17 +36,22 @@ def make_annulus_rsolid(
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.outer",
result_tag=f"feature.{tag_prefix}.outer",
)
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),
tag_prefix=f"{tag_prefix}.bore",
result_tag=f"tool.{tag_prefix}.bore",
)
annulus = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
return apply_tags(shape=annulus, tags=tags)
@scad.requires_session
def make_axis_part_rpart(
*,
part_id: str,
@@ -70,6 +78,7 @@ def make_axis_part_rpart(
return part
@scad.requires_session
def make_z_rotation_rplacement(
*,
origin: tuple[float, float, float],
@@ -94,6 +103,7 @@ def radial_centers(*, count: int, radius: float, angle_offset: float = 0.0):
yield index, angle_degrees, (radius * math.cos(angle), radius * math.sin(angle))
@scad.requires_session
def make_axial_hole_cutters_rsolids(
*,
count: int,
@@ -101,12 +111,13 @@ def make_axial_hole_cutters_rsolids(
hole_radius: float,
bottom_z: float,
height: float,
tag_prefix: str,
angle_offset: float = 0.0,
) -> list[scad.Solid]:
"""Create equally spaced axial hole cutters."""
cutters = []
for _index, _angle, center in radial_centers(
for index, _angle, center in radial_centers(
count=count,
radius=pcd / 2.0,
angle_offset=angle_offset,
@@ -117,6 +128,8 @@ def make_axial_hole_cutters_rsolids(
height=height,
bottom_face_center=(center[0], center[1], bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{tag_prefix}.hole{index + 1}",
result_tag=f"tool.{tag_prefix}.hole{index + 1}",
)
)
return cutters
@@ -126,9 +139,13 @@ 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()
local_roles = [
tag
for tag in scad.list_tags(shape=solid, scope="local")
if tag.startswith("role.")
]
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"{label}: faces={len(faces)} local_roles={len(local_roles)} "
f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}"
)
@@ -142,6 +159,7 @@ def ground_compound(*, label: str, compound: scad.Compound) -> None:
print(f"{label}: solids={len(solids)} faces={faces} volume={volume:.3f}")
@scad.requires_session
def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
"""Create a component-scoped connector reference."""
@@ -49,6 +49,7 @@ POWER_CAN_TERMINAL_CENTER = (11.0, 0.0)
MOSFET_ANGLES = (22.5, 67.5, 112.5, 202.5, 247.5, 292.5)
@scad.requires_session
def make_integrated_controller_rassembly(
*,
pcb_material: scad.Material,
@@ -139,12 +140,15 @@ def make_integrated_controller_rassembly(
return controller
@scad.requires_session
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),
tag_prefix="controller.pcb.board",
result_tag="feature.controller.pcb.board",
)
cutters: list[scad.Solid] = [
scad.make_cylinder_rsolid(
@@ -152,6 +156,8 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
height=PCB_THICKNESS + 2.0,
bottom_face_center=(0.0, 0.0, PCB_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
tag_prefix="controller.pcb.center.bore",
result_tag="tool.controller.pcb.center.bore",
)
]
cutters.extend(
@@ -161,6 +167,7 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=PCB_MOUNT_HOLE_RADIUS,
bottom_z=PCB_BOTTOM_Z - 1.0,
height=PCB_THICKNESS + 2.0,
tag_prefix="controller.pcb.mount.hole",
angle_offset=45.0,
)
)
@@ -171,9 +178,13 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=3.45,
bottom_z=PCB_BOTTOM_Z - 1.0,
height=PCB_THICKNESS + 2.0,
tag_prefix="controller.pcb.rear.column.clearance",
)
)
for x, count in ((PHASE_TERMINAL_CENTER[0], 3), (POWER_CAN_TERMINAL_CENTER[0], 4)):
for terminal_id, x, count in (
("phase", PHASE_TERMINAL_CENTER[0], 3),
("power.can", POWER_CAN_TERMINAL_CENTER[0], 4),
):
for pin in range(count):
y = (pin - (count - 1) / 2.0) * 1.8
cutters.append(
@@ -182,6 +193,8 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
height=PCB_THICKNESS + 2.0,
bottom_face_center=(x, y, PCB_BOTTOM_Z - 1.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"controller.pcb.terminal.{terminal_id}.pin{pin + 1}",
result_tag=f"tool.controller.pcb.terminal.{terminal_id}.pin{pin + 1}",
)
)
board = scad.cut_rsolid(board, cutters, skip_non_intersecting=False)
@@ -216,12 +229,15 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
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),
tag_prefix="controller.mosfet.package",
result_tag="feature.controller.mosfet.package",
)
package = apply_tags(shape=package, tags=("role.power_mosfet", "group.three_phase_bridge"))
return make_axis_part_rpart(
@@ -233,6 +249,7 @@ def _make_mosfet_package_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def _make_terminal_block_rpart(
*,
part_id: str,
@@ -241,11 +258,14 @@ def _make_terminal_block_rpart(
pin_count: int,
material: scad.Material,
) -> scad.Part:
terminal_tag_prefix = part_id.replace("_", ".")
body = scad.make_box_rsolid(
width=width,
height=6.0,
depth=4.9,
bottom_face_center=(0.0, 0.0, 0.0),
tag_prefix=f"controller.terminal.{terminal_tag_prefix}.body",
result_tag=f"feature.controller.terminal.{terminal_tag_prefix}.body",
)
access_cutters = []
for pin in range(pin_count):
@@ -256,6 +276,8 @@ def _make_terminal_block_rpart(
height=width + 2.0,
bottom_face_center=(-width / 2.0 - 1.0, y, 2.45),
axis=(1.0, 0.0, 0.0),
tag_prefix=f"controller.terminal.{terminal_tag_prefix}.access{pin + 1}",
result_tag=f"tool.controller.terminal.{terminal_tag_prefix}.access{pin + 1}",
)
)
body = scad.cut_rsolid(body, access_cutters, skip_non_intersecting=False)
@@ -110,6 +110,7 @@ except ImportError: # Support direct execution from this example directory.
)
@scad.requires_session
def make_stage_ring_gear_rpart(
*,
stage: StageSpec,
@@ -128,17 +129,25 @@ def make_stage_ring_gear_rpart(
addendum_factor=ADDENDUM_FACTOR,
clearance_factor=CLEARANCE_FACTOR,
)
ring = scad.apply_tag(
shape=ring,
tag=f"solid.stdlib.{stage.stage_id}.fixed.herringbone.ring.gear",
)
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),
tag_prefix=f"reducer.{stage.stage_id}.ring.support",
result_tag=f"feature.reducer.{stage.stage_id}.ring.support",
)
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),
tag_prefix=f"reducer.{stage.stage_id}.ring.support.bore",
result_tag=f"tool.reducer.{stage.stage_id}.ring.support.bore",
)
support = scad.cut_rsolid(support, support_bore, skip_non_intersecting=False)
ring = scad.union_rsolid(ring, support, glue=False)
@@ -159,6 +168,7 @@ def make_stage_ring_gear_rpart(
)
@scad.requires_session
def make_stage_planet_gear_rpart(
*,
stage: StageSpec,
@@ -176,12 +186,18 @@ def make_stage_planet_gear_rpart(
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
planet = scad.apply_tag(
shape=planet,
tag=f"solid.stdlib.{stage.stage_id}.reusable.herringbone.planet.gear",
)
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),
tag_prefix=f"reducer.{stage.stage_id}.planet.bearing.seat",
result_tag=f"tool.reducer.{stage.stage_id}.planet.bearing.seat",
)
planet = scad.cut_rsolid(planet, bearing_seat, skip_non_intersecting=False)
planet = apply_tags(
@@ -205,6 +221,7 @@ def make_stage_planet_gear_rpart(
)
@scad.requires_session
def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part:
"""Create the first carrier and integral second-stage sun/shaft."""
@@ -223,6 +240,8 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part:
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),
tag_prefix="reducer.stage1.carrier.interstage.shaft",
result_tag="feature.reducer.stage1.carrier.interstage.shaft",
)
stage2_sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=STAGE_2.sun_teeth,
@@ -234,6 +253,10 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part:
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
stage2_sun = scad.apply_tag(
shape=stage2_sun,
tag="solid.stdlib.stage2.integral.herringbone.sun.gear",
)
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(
@@ -270,6 +293,7 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def make_output_carrier_flange_rpart(
*,
stage: StageSpec,
@@ -297,12 +321,16 @@ def make_output_carrier_flange_rpart(
),
bottom_face_center=(0.0, 0.0, STAGE2_CARRIER_BOTTOM_Z - 0.05),
axis=(0.0, 0.0, 1.0),
tag_prefix="reducer.stage2.output.shaft",
result_tag="feature.reducer.stage2.output.shaft",
)
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),
tag_prefix="reducer.stage2.output.flange",
result_tag="feature.reducer.stage2.output.flange",
)
output = scad.union_rsolid(carrier, shaft, flange, glue=False)
output = scad.cut_rsolid(
@@ -313,6 +341,7 @@ def make_output_carrier_flange_rpart(
hole_radius=OUTPUT_LINK_TAP_RADIUS,
bottom_z=OUTPUT_FLANGE_TOP_Z - OUTPUT_LINK_THREAD_DEPTH,
height=OUTPUT_LINK_THREAD_DEPTH + 1.0,
tag_prefix="reducer.stage2.output.flange.thread",
angle_offset=OUTPUT_LINK_BOLT_ANGLES_DEGREES[0],
),
skip_non_intersecting=False,
@@ -351,6 +380,7 @@ def make_output_carrier_flange_rpart(
)
@scad.requires_session
def make_planet_rplacement(*, stage: StageSpec, index: int) -> scad.Placement:
"""Place and visually phase one planet at its pitch center."""
@@ -377,6 +407,7 @@ def planet_center_xy(*, stage: StageSpec, index: int) -> tuple[float, float]:
)
@scad.requires_session
def _make_carrier_body_rsolid(
*,
stage: StageSpec,
@@ -393,6 +424,8 @@ def _make_carrier_body_rsolid(
height=plate_thickness,
bottom_face_center=(0.0, 0.0, plate_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"reducer.{stage.stage_id}.carrier.hub",
result_tag=f"feature.reducer.{stage.stage_id}.carrier.hub",
)
solids = [hub]
pin_height = plate_bottom_z + plate_thickness - pin_bottom_z
@@ -408,6 +441,8 @@ def _make_carrier_body_rsolid(
height=arm_width,
depth=plate_thickness,
bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z),
tag_prefix=f"reducer.{stage.stage_id}.carrier.arm{index + 1}",
result_tag=f"feature.reducer.{stage.stage_id}.carrier.arm{index + 1}",
)
solids.append(
scad.rotate_shape(
@@ -423,6 +458,8 @@ def _make_carrier_body_rsolid(
height=plate_thickness,
bottom_face_center=(center[0], center[1], plate_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"reducer.{stage.stage_id}.carrier.pad{index + 1}",
result_tag=f"feature.reducer.{stage.stage_id}.carrier.pad{index + 1}",
)
)
solids.append(
@@ -431,6 +468,8 @@ def _make_carrier_body_rsolid(
height=pin_height,
bottom_face_center=(center[0], center[1], pin_bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"reducer.{stage.stage_id}.carrier.pin{index + 1}",
result_tag=f"feature.reducer.{stage.stage_id}.carrier.pin{index + 1}",
)
)
carrier = scad.union_rsolid(solids, glue=False)
@@ -102,6 +102,7 @@ except ImportError: # Support direct execution from this example directory.
)
@scad.requires_session
def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
"""Create the stator sleeve, front attachment land, and rear columns."""
@@ -110,6 +111,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=MOTOR_SHELL_INNER_RADIUS,
bottom_z=MOTOR_SHELL_BOTTOM_Z,
height=MOTOR_SHELL_TOP_Z - MOTOR_SHELL_BOTTOM_Z,
tag_prefix="housing.motor.shell.sleeve",
tags=("role.motor_shell", "role.stator_thermal_path"),
)
front_land = make_annulus_rsolid(
@@ -117,16 +119,19 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS,
bottom_z=MOTOR_SHELL_TOP_Z - 1.4,
height=1.4,
tag_prefix="housing.motor.shell.front.land",
tags=("role.motor_reducer_mount",),
)
columns = []
for _index, _angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0):
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),
tag_prefix=f"housing.motor.shell.rear.column{index + 1}",
result_tag=f"feature.housing.motor.shell.rear.column{index + 1}",
)
)
shell = scad.union_rsolid(sleeve, front_land, columns, glue=False)
@@ -138,6 +143,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=MOTOR_SHELL_TOP_Z - 2.8,
height=3.6,
tag_prefix="housing.motor.shell.interface.clearance",
angle_offset=30.0,
),
make_axial_hole_cutters_rsolids(
@@ -146,6 +152,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=MOTOR_SHELL_BOTTOM_Z - 1.0,
height=10.4,
tag_prefix="housing.motor.shell.rear.fastener.clearance",
),
skip_non_intersecting=False,
)
@@ -175,6 +182,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
"""Create the reducer sleeve and front motor-bearing bulkhead."""
@@ -183,6 +191,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=REDUCER_HOUSING_INNER_RADIUS,
bottom_z=REDUCER_HOUSING_BOTTOM_Z,
height=REDUCER_HOUSING_FRONT_Z - REDUCER_HOUSING_BOTTOM_Z,
tag_prefix="housing.reducer.sleeve",
tags=("role.reducer_housing_sleeve",),
)
bulkhead = make_annulus_rsolid(
@@ -190,6 +199,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
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,
tag_prefix="housing.reducer.front.bulkhead",
tags=("role.motor_front_bearing_bulkhead",),
)
interstage_divider = make_annulus_rsolid(
@@ -197,6 +207,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
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,
tag_prefix="housing.reducer.interstage.divider",
tags=("role.interstage_bearing_divider",),
)
output_mount_land = make_annulus_rsolid(
@@ -204,6 +215,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS,
bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2,
height=2.2,
tag_prefix="housing.reducer.output.mount.land",
tags=("role.output_cap_mount_land",),
)
housing = scad.union_rsolid(
@@ -221,6 +233,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=REDUCER_HOUSING_BOTTOM_Z - 1.0,
height=STAGE_1.bottom_z - REDUCER_HOUSING_BOTTOM_Z + 2.0,
tag_prefix="housing.reducer.motor.interface.clearance",
angle_offset=30.0,
),
make_axial_hole_cutters_rsolids(
@@ -229,6 +242,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2,
height=3.2,
tag_prefix="housing.reducer.output.interface.clearance",
),
skip_non_intersecting=False,
)
@@ -265,6 +279,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
"""Create a four-arm removable rear motor-bearing support."""
@@ -274,15 +289,18 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=8.05,
bottom_z=bottom_z,
height=5.0,
tag_prefix="housing.rear.spider.bearing.hub",
tags=("role.rear_motor_bearing_seat",),
)
solids = [hub]
for _index, angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0):
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),
tag_prefix=f"housing.rear.spider.arm{index + 1}",
result_tag=f"feature.housing.rear.spider.arm{index + 1}",
)
solids.append(
scad.rotate_shape(
@@ -298,6 +316,8 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
height=5.0,
bottom_face_center=(center[0], center[1], bottom_z),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"housing.rear.spider.boss{index + 1}",
result_tag=f"feature.housing.rear.spider.boss{index + 1}",
)
)
spider = scad.union_rsolid(solids, glue=False)
@@ -309,6 +329,7 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=bottom_z - 1.0,
height=7.0,
tag_prefix="housing.rear.spider.fastener.clearance",
),
skip_non_intersecting=False,
)
@@ -329,6 +350,7 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rear cover with PCB standoffs and terminal apertures."""
@@ -337,15 +359,23 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
height=REAR_COVER_THICKNESS,
bottom_face_center=(0.0, 0.0, REAR_COVER_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
tag_prefix="housing.rear.cover.plate",
result_tag="feature.housing.rear.cover.plate",
)
standoffs = []
for _index, _angle, center in radial_centers(count=4, radius=PCB_STANDOFF_PCD / 2.0, angle_offset=45.0):
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),
tag_prefix=f"housing.rear.cover.pcb.standoff{index + 1}",
result_tag=f"feature.housing.rear.cover.pcb.standoff{index + 1}",
)
)
cover = scad.union_rsolid(cover, standoffs, glue=False)
@@ -354,18 +384,24 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
height=7.2,
depth=REAR_COVER_THICKNESS + 2.0,
bottom_face_center=(-11.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0),
tag_prefix="housing.rear.cover.phase.aperture",
result_tag="tool.housing.rear.cover.phase.aperture",
)
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),
tag_prefix="housing.rear.cover.power.can.aperture",
result_tag="tool.housing.rear.cover.power.can.aperture",
)
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),
tag_prefix="housing.rear.cover.center.service",
result_tag="tool.housing.rear.cover.center.service",
)
cover = scad.cut_rsolid(
cover,
@@ -378,6 +414,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=REAR_FASTENER_HOLE_RADIUS,
bottom_z=REAR_COVER_BOTTOM_Z - 1.0,
height=REAR_COVER_THICKNESS + 2.0,
tag_prefix="housing.rear.cover.column.fastener.clearance",
),
make_axial_hole_cutters_rsolids(
count=4,
@@ -385,6 +422,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=1.1,
bottom_z=REAR_COVER_BOTTOM_Z - 1.0,
height=PCB_BOTTOM_Z - REAR_COVER_BOTTOM_Z + 2.0,
tag_prefix="housing.rear.cover.pcb.fastener.clearance",
angle_offset=45.0,
),
skip_non_intersecting=False,
@@ -408,6 +446,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
"""Create the removable paired-bearing cartridge and front cap."""
@@ -417,6 +456,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=bearing_clearance_radius,
bottom_z=OUTPUT_CAP_BOTTOM_Z,
height=3.0,
tag_prefix="housing.output.cap.rear.flange",
tags=("role.output_cap_mount_flange",),
)
cartridge = make_annulus_rsolid(
@@ -424,6 +464,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=bearing_clearance_radius,
bottom_z=OUTPUT_CAP_BOTTOM_Z,
height=OUTPUT_CAP_CARTRIDGE_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 0.1,
tag_prefix="housing.output.cap.bearing.cartridge",
tags=("role.paired_output_bearing_seat",),
)
bearing_retainer = make_annulus_rsolid(
@@ -431,6 +472,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
inner_radius=8.10,
bottom_z=OUTPUT_CAP_CARTRIDGE_TOP_Z - 0.1,
height=0.5,
tag_prefix="housing.output.cap.bearing.retainer",
tags=("role.output_axial_retainer",),
)
outer_lip = make_annulus_rsolid(
@@ -438,6 +480,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
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,
tag_prefix="housing.output.cap.labyrinth.lip",
tags=("role.output_labyrinth_lip",),
)
cap = scad.union_rsolid(rear_flange, cartridge, bearing_retainer, outer_lip, glue=False)
@@ -449,6 +492,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part:
hole_radius=M3_CLEARANCE_RADIUS,
bottom_z=OUTPUT_CAP_BOTTOM_Z - 1.0,
height=OUTPUT_CAP_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 2.0,
tag_prefix="housing.output.cap.interface.clearance",
),
skip_non_intersecting=False,
)
@@ -1,4 +1,4 @@
"""Build, validate, replay, and export the integrated BLDC joint actuator."""
"""Build and export the integrated BLDC joint actuator."""
from __future__ import annotations
@@ -8,19 +8,34 @@ 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
try:
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
except ImportError: # Support direct execution from this example directory.
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)
@@ -28,18 +43,21 @@ sys.setrecursionlimit(30000)
OUT_DIR = Path("examples/out/integrated_bldc_joint_actuator")
@scad.model(graph_id="integrated_50mm_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
assembly = make_integrated_bldc_joint_actuator_rassembly(materials=materials)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
preview = scad.apply_tag(
shape=preview,
tag="scene.integrated.bldc.joint.actuator.preview",
)
ground_compound(label="integrated_actuator_preview", compound=preview)
scad.capture_result(value=(assembly, preview))
return assembly, preview
def main() -> None:
@@ -53,19 +71,18 @@ def main() -> None:
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")
result = build_integrated_bldc_joint_actuator()
assembly, preview = result.value
model_path.write_text(result.model_json, encoding="utf-8")
session_path.write_text(result.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)
payload = json.loads(result.model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
json_str=result.model_json,
output_path=str(fcstd_path.resolve()),
document_name="Integrated50mmBLDCJointActuator",
freecad_cmd=None,
@@ -84,9 +101,6 @@ def main() -> None:
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}")
@@ -5,6 +5,7 @@ from __future__ import annotations
import simplecadapi as scad
@scad.requires_session
def make_actuator_materials_rdict() -> dict[str, scad.Material]:
"""Create the materials used by Case 20."""
@@ -76,6 +76,7 @@ except ImportError: # Support direct execution from this example directory.
)
@scad.requires_session
def make_bldc_stator_rassembly(
*,
steel_material: scad.Material,
@@ -130,6 +131,7 @@ def make_bldc_stator_rassembly(
return stator
@scad.requires_session
def make_bldc_rotor_rassembly(
*,
steel_material: scad.Material,
@@ -190,23 +192,27 @@ def make_bldc_rotor_rassembly(
return rotor
@scad.requires_session
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,
tag_prefix="motor.stator.back.iron",
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):
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),
tag_prefix=f"motor.stator.tooth{index + 1}",
result_tag=f"feature.motor.stator.tooth{index + 1}",
)
teeth.append(
scad.rotate_shape(
@@ -255,6 +261,7 @@ def _make_stator_core_rpart(*, material: scad.Material) -> scad.Part:
return part
@scad.requires_session
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
@@ -263,24 +270,32 @@ def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part:
height=1.2,
depth=side_depth,
bottom_face_center=(17.55, 2.1, side_bottom_z),
tag_prefix="motor.winding.side.positive",
result_tag="feature.motor.winding.side.positive",
)
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),
tag_prefix="motor.winding.side.negative",
result_tag="feature.motor.winding.side.negative",
)
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),
tag_prefix="motor.winding.rear.end.turn",
result_tag="feature.motor.winding.rear.end.turn",
)
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),
tag_prefix="motor.winding.front.end.turn",
result_tag="feature.motor.winding.front.end.turn",
)
winding = scad.union_rsolid(
side_positive,
@@ -302,6 +317,7 @@ def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part:
)
@scad.requires_session
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(
@@ -309,12 +325,16 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part:
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),
tag_prefix="motor.rotor.drive.shaft",
result_tag="feature.motor.rotor.drive.shaft",
)
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),
tag_prefix="motor.rotor.back.iron",
result_tag="feature.motor.rotor.back.iron",
)
sun = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=STAGE_1.sun_teeth,
@@ -326,6 +346,10 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part:
clearance_factor=CLEARANCE_FACTOR,
backlash=BACKLASH,
)
sun = scad.apply_tag(
shape=sun,
tag="solid.stdlib.stage1.integral.herringbone.sun.gear",
)
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(
@@ -354,6 +378,7 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part:
return part
@scad.requires_session
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
@@ -363,6 +388,8 @@ def _make_rotor_magnet_rpart(*, material: scad.Material) -> scad.Part:
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),
tag_prefix="motor.rotor.reusable.magnet",
result_tag="feature.motor.rotor.reusable.magnet",
)
magnet = apply_tags(shape=magnet, tags=("role.rotor_magnet", "group.rotor_magnets"))
print(
+16 -16
View File
@@ -1,22 +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.
Generated artifacts are written to `examples/out/`, which is ignored by git.
Replayable examples use one top-level `@scad.model` entry. That entry owns the
single `GraphSession` and returns a `ModelResult`; reusable graph-producing
builders use `@scad.requires_session`. Final outputs are selected with
`scad.capture_result(...)`; `@scad.model(export_dir=...)` can automatically
write one self-contained Scene ZIP after execution. It embeds the replayable
model, mapped Python source files, render GLBs, and entity sidecars. Examples
that call explicit export or translator APIs may also write STEP, JSON, or
FreeCAD files.
## 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.
- `04_dimension_tolerance_chain.py` — expression-driven dimension tolerance analysis and validation.
- `08_constrained_sketch.py` — fully constrained sketch profiles, feature promotion, replay, and FreeCAD export.
- `09_naca0016_blade_freecad.py` — NACA 0016 B-spline blade model JSON, STEP, and FreeCAD translation.
- `10_part_assembly.py` — hydraulic rod assembly with sleeve/piston parts, prismatic motion, and automatic self-contained Scene ZIP export.
- `16_compact_two_stage_planetary_reducer/` — modular 58.8 mm diameter, 30 mm tall, 20:1 two-stage herringbone planetary reducer with graph/model JSON replay, STEP export, solved constraints, and a static collision probe.
- `20_integrated_bldc_joint_actuator/` — compact 50 mm OD joint actuator with a 12-slot/14-pole inner-rotor BLDC motor, two-stage planetary reducer, split housing, output bearings, and controller electronics.