Files
reducers/backend/src/cad/simplecad_cascade_generator.py
T
2026-08-14 10:26:28 +08:00

1589 lines
65 KiB
Python

from __future__ import annotations
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..cascade_physical import (
CascadeInterstageDesign,
compute_cascade_interstage_design,
stage_carrier_plate_thickness_mm,
)
from ..configurations.simple_2k_h_cascade.family import (
local_role,
stage_component_id,
stage_parameter_by_id,
)
from ..models import (
AssemblyManifest,
ComponentManifest,
ConstraintManifest,
ReducerInstance,
StageDerived,
StageParameters,
sha256_json,
utc_now_iso,
write_json,
write_model_json,
)
from ..placement import PlacementPlan, solve_reducer_placements
from .gear_factory import GearSpec, hand_for_component, make_external_gear, make_internal_ring_gear
from .simplecad_assembly import add_forwarded_connector
from .simplecad_parts import (
add_axis_connector,
internal_ring_phase_correction_degrees,
make_axis_part,
make_materials,
make_z_rotation_placement,
)
@dataclass(frozen=True)
class StageBuildContext:
instance: ReducerInstance
stage: StageParameters
derived: StageDerived
z_offset_mm: float
stage_gap_mm: float
coupling_axis_z_mm: float | None = None
@property
def z_mid_mm(self) -> float:
return self.z_offset_mm + self.stage.face_width_mm / 2.0
def _stage_contexts(instance: ReducerInstance) -> list[StageBuildContext]:
if not instance.parameters.stages or not instance.derived.stages:
raise RuntimeError("cascade CAD generation requires stage parameters and derived values")
derived_by_stage = {stage.stage_id: stage for stage in instance.derived.stages}
stage_gap = float((instance.derived.axial_stack or {}).get("stage_gap_mm", 2.0))
offsets = (instance.derived.axial_stack or {}).get("stage_offsets_mm", {})
contexts: list[StageBuildContext] = []
for index, stage in enumerate(instance.parameters.stages):
z_offset = float(offsets.get(stage.stage_id, 0.0 if index == 0 else contexts[-1].z_offset_mm + contexts[-1].stage.face_width_mm + stage_gap))
contexts.append(
StageBuildContext(
instance=instance,
stage=stage,
derived=derived_by_stage[stage.stage_id],
z_offset_mm=z_offset,
stage_gap_mm=stage_gap,
)
)
if len(contexts) == 2 and instance.requirement.stages:
first_requirement, second_requirement = instance.requirement.stages
first_output = first_requirement.output
target_mid = contexts[1].z_mid_mm
contexts = [
(
StageBuildContext(
instance=contexts[0].instance,
stage=contexts[0].stage,
derived=contexts[0].derived,
z_offset_mm=contexts[0].z_offset_mm,
stage_gap_mm=contexts[0].stage_gap_mm,
coupling_axis_z_mm=target_mid - contexts[0].z_offset_mm,
)
if first_output
else contexts[0]
),
contexts[1],
]
return contexts
def _bore_radius_for_sun(ctx: StageBuildContext) -> float:
return max(0.8, min(2.2, ctx.derived.sun_pitch_radius_mm * 0.35))
def _pin_radius_for_planet(ctx: StageBuildContext) -> float:
return max(0.6, min(2.0, ctx.derived.planet_pitch_radius_mm * 0.22))
def _ring_solid(
scad: Any,
*,
outer_radius: float,
inner_radius: float,
height: float,
bottom_z: float,
) -> Any:
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
return scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
def _component_role(instance: ReducerInstance, component_id: str) -> str:
if component_id == instance.boundary.input:
return "input"
if component_id == instance.boundary.output:
return "output"
if component_id in (instance.boundary.fixed_members or []):
return "fixed"
if instance.requirement.stages and len(instance.requirement.stages) == 2:
first, second = instance.requirement.stages
if component_id in {
stage_component_id(first.stage_id, first.output),
stage_component_id(second.stage_id, second.input),
}:
return "coupled"
if component_id == "housing":
return "housing"
if "_planet_" in component_id:
return "planet"
return local_role(component_id)
def _apply_tags(scad: Any, *, shape: Any, ctx: StageBuildContext, component_id: str) -> Any:
role = _component_role(ctx.instance, component_id)
tagged = scad.apply_tag(shape=shape, tag=f"formula.run_id.{ctx.instance.run_id}")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.node.{component_id}")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.stage.{ctx.stage.stage_id}")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.local_role.{local_role(component_id)}")
if role in {"input", "fixed", "output", "coupled"}:
tagged = scad.apply_tag(shape=tagged, tag=f"formula.role.{role}")
return tagged
def _apply_cascade_tags(
scad: Any,
*,
shape: Any,
instance: ReducerInstance,
component_id: str,
formula_ref: str,
) -> Any:
tagged = scad.apply_tag(shape=shape, tag=f"formula.run_id.{instance.run_id}")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.node.{component_id}")
tagged = scad.apply_tag(shape=tagged, tag="formula.stage.cascade")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.ref.{formula_ref}")
return tagged
def _gear_spec(ctx: StageBuildContext, component: str) -> GearSpec:
stage = ctx.stage
local = "planet" if component.startswith("planet") else component
if local == "sun":
return GearSpec(
component="sun",
n_teeth=stage.z_sun,
gear_type="external",
module_mm=stage.module_mm,
pressure_angle_deg=stage.pressure_angle_deg,
face_width_mm=stage.face_width_mm,
addendum_coeff=stage.addendum_coeff,
clearance_coeff=stage.clearance_coeff,
backlash_mm=stage.backlash_mm,
tooth_form=stage.tooth_form,
helix_angle_deg=stage.helix_angle_deg,
helix_hand=hand_for_component("sun", base_hand=stage.helix_hand),
)
if local == "planet":
return GearSpec(
component="planet",
n_teeth=stage.z_planet,
gear_type="external",
module_mm=stage.module_mm,
pressure_angle_deg=stage.pressure_angle_deg,
face_width_mm=stage.face_width_mm,
addendum_coeff=stage.addendum_coeff,
clearance_coeff=stage.clearance_coeff,
backlash_mm=stage.backlash_mm,
tooth_form=stage.tooth_form,
helix_angle_deg=stage.helix_angle_deg,
helix_hand=hand_for_component("planet", base_hand=stage.helix_hand),
)
if local == "ring":
return GearSpec(
component="ring",
n_teeth=stage.z_ring,
gear_type="internal",
module_mm=stage.module_mm,
pressure_angle_deg=stage.pressure_angle_deg,
face_width_mm=stage.face_width_mm,
addendum_coeff=stage.addendum_coeff,
clearance_coeff=stage.clearance_coeff,
backlash_mm=stage.backlash_mm,
tooth_form=stage.tooth_form,
helix_angle_deg=stage.helix_angle_deg,
helix_hand=hand_for_component("ring", base_hand=stage.helix_hand),
ring_rim_thickness_mm=stage.ring_rim_thickness_mm,
)
raise ValueError(f"unsupported gear component: {component}")
def _connector_specs(
ctx: StageBuildContext,
*,
component_id: str,
base_specs: list[tuple[str, tuple[float, float, float], str]],
) -> tuple[tuple[str, tuple[float, float, float], str], ...]:
if not ctx.instance.requirement.stages:
return tuple(base_specs)
first = ctx.instance.requirement.stages[0]
if component_id == stage_component_id(first.stage_id, first.output) and ctx.coupling_axis_z_mm is not None:
base_specs.append(("coupling_axis", (0.0, 0.0, ctx.coupling_axis_z_mm), "Coupling axis to next stage"))
return tuple(base_specs)
def make_stage_sun(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
stage = ctx.stage
component_id = stage_component_id(stage.stage_id, "sun")
gear = make_external_gear(scad, _gear_spec(ctx, "sun"))
bore = scad.make_cylinder_rsolid(
radius=_bore_radius_for_sun(ctx),
height=stage.face_width_mm + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
gear = scad.cut_rsolid(gear, bore, skip_non_intersecting=False)
gear = _apply_tags(scad, shape=gear, ctx=ctx, component_id=component_id)
return make_axis_part(
scad,
part_id=f"{component_id}_gear",
body=gear,
name=f"{component_id} {stage.tooth_form} sun gear",
material=material,
connector_specs=_connector_specs(
ctx,
component_id=component_id,
base_specs=[("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Sun axis")],
),
)
def make_stage_planet(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
stage = ctx.stage
gear = make_external_gear(scad, _gear_spec(ctx, "planet"))
bore = scad.make_cylinder_rsolid(
radius=_pin_radius_for_planet(ctx) + 0.25,
height=stage.face_width_mm + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
gear = scad.cut_rsolid(gear, bore, skip_non_intersecting=False)
gear = scad.apply_tag(shape=gear, tag=f"formula.run_id.{ctx.instance.run_id}")
gear = scad.apply_tag(shape=gear, tag=f"formula.stage.{stage.stage_id}")
gear = scad.apply_tag(shape=gear, tag=f"formula.node.{stage.stage_id}_planet")
return make_axis_part(
scad,
part_id=f"{stage.stage_id}_planet_gear",
body=gear,
name=f"{stage.stage_id} reusable {stage.tooth_form} planet gear",
material=material,
connector_specs=(("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Planet spin axis"),),
)
def make_stage_ring(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
stage = ctx.stage
component_id = stage_component_id(stage.stage_id, "ring")
gear = make_internal_ring_gear(scad, _gear_spec(ctx, "ring"))
phase = internal_ring_phase_correction_degrees(z_planet=stage.z_planet, z_ring=stage.z_ring)
if abs(phase) > 1e-12:
gear = scad.rotate_shape(
shape=gear,
angle=phase,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
gear = _apply_tags(scad, shape=gear, ctx=ctx, component_id=component_id)
return make_axis_part(
scad,
part_id=f"{component_id}_gear",
body=gear,
name=f"{component_id} internal {stage.tooth_form} ring gear",
material=material,
connector_specs=_connector_specs(
ctx,
component_id=component_id,
base_specs=[("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Ring axis")],
),
)
def _planet_angle_degrees(ctx: StageBuildContext, index: int) -> float:
return 360.0 * index / ctx.stage.planet_count
def _planet_center_xy(ctx: StageBuildContext, index: int) -> tuple[float, float]:
angle = math.radians(_planet_angle_degrees(ctx, index))
radius = ctx.derived.planet_orbit_radius_mm
return radius * math.cos(angle), radius * math.sin(angle)
def _planet_placement(scad: Any, *, ctx: StageBuildContext, index: int) -> Any:
x, y = _planet_center_xy(ctx, index)
center_angle = _planet_angle_degrees(ctx, index)
phase = (180.0 - 180.0 / ctx.stage.z_planet) + center_angle * (
1.0 + ctx.stage.z_sun / ctx.stage.z_planet
)
return make_z_rotation_placement(
scad,
origin=(x, y, ctx.z_offset_mm),
angle_degrees=phase,
)
def make_stage_carrier(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
stage = ctx.stage
component_id = stage_component_id(stage.stage_id, "carrier")
plate_thickness = stage_carrier_plate_thickness_mm(ctx.stage_gap_mm)
plate_bottom = stage.face_width_mm + 0.25
pin_radius = _pin_radius_for_planet(ctx)
hub_radius = max(_bore_radius_for_sun(ctx) + 2.0, ctx.derived.sun_pitch_radius_mm * 0.75)
disk_radius = ctx.derived.planet_orbit_radius_mm + pin_radius * 2.5
disk = scad.make_cylinder_rsolid(
radius=disk_radius,
height=plate_thickness,
bottom_face_center=(0.0, 0.0, plate_bottom),
axis=(0.0, 0.0, 1.0),
)
hub = scad.make_cylinder_rsolid(
radius=hub_radius,
height=plate_thickness,
bottom_face_center=(0.0, 0.0, plate_bottom),
axis=(0.0, 0.0, 1.0),
)
pins = []
bosses = []
for index in range(stage.planet_count):
x, y = _planet_center_xy(ctx, index)
bosses.append(
scad.make_cylinder_rsolid(
radius=pin_radius * 1.8,
height=plate_thickness,
bottom_face_center=(x, y, plate_bottom),
axis=(0.0, 0.0, 1.0),
)
)
pins.append(
scad.make_cylinder_rsolid(
radius=pin_radius,
height=stage.face_width_mm + 0.25 + plate_thickness,
bottom_face_center=(x, y, 0.0),
axis=(0.0, 0.0, 1.0),
)
)
body = scad.union_rsolid([disk, hub, bosses, pins], glue=False)
center_bore = scad.make_cylinder_rsolid(
radius=_bore_radius_for_sun(ctx) + 0.35,
height=plate_thickness + 2.0,
bottom_face_center=(0.0, 0.0, plate_bottom - 1.0),
axis=(0.0, 0.0, 1.0),
)
body = scad.cut_rsolid(body, center_bore, skip_non_intersecting=False)
body = _apply_tags(scad, shape=body, ctx=ctx, component_id=component_id)
connectors = [
("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Carrier axis"),
]
connectors.extend(
(
f"{ctx.stage.stage_id}_planet_{index + 1}_axis",
(*_planet_center_xy(ctx, index), stage.face_width_mm / 2.0),
f"Planet {index + 1} pin axis",
)
for index in range(stage.planet_count)
)
return make_axis_part(
scad,
part_id=f"{component_id}",
body=body,
name=f"{component_id} carrier with {stage.planet_count} pins",
material=material,
connector_specs=_connector_specs(ctx, component_id=component_id, base_specs=list(connectors)),
)
def make_cascade_housing(
scad: Any,
*,
contexts: list[StageBuildContext],
material: Any,
transmission_design: CascadeInterstageDesign,
) -> Any:
connector_specs = [
(
f"{ctx.stage.stage_id}_axis",
(0.0, 0.0, ctx.z_mid_mm),
f"{ctx.stage.stage_id} housing axis",
)
for ctx in contexts
]
connector_specs.extend(
(
(f"{component_id}_axis", (0.0, 0.0, center_z), f"{component_id} housing seat axis")
for component_id, center_z in transmission_design.bearing_centers_z_mm.items()
)
)
connector_specs.extend(
(
(
f"{component_id}_axis",
(0.0, 0.0, center_z),
f"{component_id} bulkhead axis",
)
for component_id, center_z in transmission_design.support_centers_z_mm.items()
)
)
bottom_z = min(ctx.z_offset_mm - 2.2 for ctx in contexts)
top_z = transmission_design.shaft_top_z_mm + 0.50
body = _ring_solid(
scad,
outer_radius=transmission_design.housing_outer_diameter_mm / 2.0,
inner_radius=transmission_design.housing_inner_diameter_mm / 2.0,
height=top_z - bottom_z,
bottom_z=bottom_z,
)
body = scad.apply_tag(shape=body, tag=f"formula.run_id.{contexts[0].instance.run_id}")
body = scad.apply_tag(shape=body, tag="formula.node.housing")
body = scad.apply_tag(shape=body, tag="formula.ref.cascade.common_housing")
return make_axis_part(
scad,
part_id="cascade_housing",
body=body,
name="Cascade common housing shell with interstage bearing seats",
material=material,
connector_specs=tuple(connector_specs),
)
def make_interstage_shaft(
scad: Any,
*,
instance: ReducerInstance,
design: CascadeInterstageDesign,
material: Any,
) -> Any:
body = scad.make_cylinder_rsolid(
radius=design.shaft_diameter_mm / 2.0,
height=design.shaft_span_mm,
bottom_face_center=(0.0, 0.0, design.shaft_bottom_z_mm),
axis=(0.0, 0.0, 1.0),
)
body = _apply_cascade_tags(
scad,
shape=body,
instance=instance,
component_id="interstage_shaft",
formula_ref="cascade.interstage_shaft",
)
connector_specs = [
("axis", (0.0, 0.0, (design.shaft_bottom_z_mm + design.shaft_top_z_mm) / 2.0), "Interstage shaft axis"),
("s1_output_axis", (0.0, 0.0, design.s1_output_axis_z_mm), "Stage 1 carrier drive axis"),
("s2_input_axis", (0.0, 0.0, design.s2_input_axis_z_mm), "Stage 2 sun driven axis"),
("coupler_axis", (0.0, 0.0, design.coupler_center_z_mm), "Interstage coupler axis"),
]
connector_specs.extend(
(f"{component_id}_axis", (0.0, 0.0, center_z), f"{component_id} inner ring axis")
for component_id, center_z in design.bearing_centers_z_mm.items()
)
return make_axis_part(
scad,
part_id="interstage_shaft",
body=body,
name="Physical interstage shaft from s1 carrier to s2 sun",
material=material,
connector_specs=tuple(connector_specs),
)
def make_cascade_port_shaft(
scad: Any,
*,
instance: ReducerInstance,
placement_plan: PlacementPlan,
component_id: str,
radius_mm: float,
material: Any,
) -> Any:
pose = placement_plan.require_pose(component_id)
z0, z1 = pose.details["z_span_mm"]
height = max(0.1, float(z1) - float(z0))
body = scad.make_cylinder_rsolid(
radius=radius_mm,
height=height,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
body = _apply_cascade_tags(
scad,
shape=body,
instance=instance,
component_id=component_id,
formula_ref=f"cascade.{component_id}",
)
return make_axis_part(
scad,
part_id=component_id,
body=body,
name=component_id.replace("_", " ").title(),
material=material,
connector_specs=(("axis", (0.0, 0.0, height / 2.0), f"{component_id} axis"),),
)
def make_interstage_coupler(
scad: Any,
*,
instance: ReducerInstance,
design: CascadeInterstageDesign,
material: Any,
) -> Any:
body = _ring_solid(
scad,
outer_radius=design.coupler_outer_diameter_mm / 2.0,
inner_radius=design.coupler_inner_diameter_mm / 2.0,
height=design.coupler_width_mm,
bottom_z=-design.coupler_width_mm / 2.0,
)
body = _apply_cascade_tags(
scad,
shape=body,
instance=instance,
component_id="interstage_coupler",
formula_ref="cascade.interstage_coupler",
)
return make_axis_part(
scad,
part_id="interstage_coupler",
body=body,
name="Interstage keyed coupling sleeve",
material=material,
connector_specs=(("axis", (0.0, 0.0, 0.0), "Interstage coupler axis"),),
)
def make_interstage_support(
scad: Any,
*,
instance: ReducerInstance,
design: CascadeInterstageDesign,
component_id: str,
material: Any,
) -> Any:
body = _ring_solid(
scad,
outer_radius=design.support_outer_diameter_mm / 2.0,
inner_radius=design.support_inner_diameter_mm / 2.0,
height=design.support_width_mm,
bottom_z=-design.support_width_mm / 2.0,
)
body = _apply_cascade_tags(
scad,
shape=body,
instance=instance,
component_id=component_id,
formula_ref=f"cascade.{component_id}",
)
return make_axis_part(
scad,
part_id=component_id,
body=body,
name=component_id.replace("_", " ").title(),
material=material,
connector_specs=(("axis", (0.0, 0.0, 0.0), f"{component_id} axis"),),
)
def make_cascade_ball_bearing(
scad: Any,
*,
instance: ReducerInstance,
design: CascadeInterstageDesign,
component_id: str,
material: Any,
) -> Any:
bore_radius = design.bearing_inner_diameter_mm / 2.0
outer_radius = design.bearing_outer_diameter_mm / 2.0
width = design.bearing_width_mm
ball_radius = design.bearing_ball_diameter_mm / 2.0
raceway_clearance = max(0.01, design.bearing_ball_diameter_mm * 0.08)
ball_pitch_radius = (bore_radius + outer_radius) / 2.0
inner_ring_outer_radius = ball_pitch_radius - ball_radius - raceway_clearance
outer_ring_inner_radius = ball_pitch_radius + ball_radius + raceway_clearance
if inner_ring_outer_radius <= bore_radius:
raise RuntimeError(f"{component_id}: interstage bearing has no inner ring wall")
if outer_ring_inner_radius >= outer_radius:
raise RuntimeError(f"{component_id}: interstage bearing has no outer ring wall")
assembly = scad.make_assembly_rassembly(
assembly_id=f"{component_id}_radial_ball_bearing",
name=f"{component_id.replace('_', ' ').title()} radial ball bearing",
)
identity = scad.identity_placement_rplacement()
def bearing_part(part_id: str, body: Any, name: str) -> Any:
body = _apply_cascade_tags(
scad,
shape=body,
instance=instance,
component_id=component_id,
formula_ref=f"cascade.{component_id}.{part_id}",
)
return make_axis_part(
scad,
part_id=part_id,
body=body,
name=name,
material=material,
connector_specs=(("axis", (0.0, 0.0, 0.0), f"{name} axis"),),
)
outer_ring = _ring_solid(
scad,
outer_radius=outer_radius,
inner_radius=outer_ring_inner_radius,
height=width,
bottom_z=-width / 2.0,
)
inner_ring = _ring_solid(
scad,
outer_radius=inner_ring_outer_radius,
inner_radius=bore_radius,
height=width,
bottom_z=-width / 2.0,
)
cage_thickness = min(width * 0.12, max(0.06, design.bearing_ball_diameter_mm * 0.18))
cage_radius_half_width = max(0.035, ball_radius * 0.30)
cage_inner_radius = max(bore_radius, ball_pitch_radius - cage_radius_half_width)
cage_outer_radius = min(outer_radius, ball_pitch_radius + cage_radius_half_width)
cage_z_offset = min(width * 0.35, ball_radius + raceway_clearance + cage_thickness / 2.0)
cage_front = _ring_solid(
scad,
outer_radius=cage_outer_radius,
inner_radius=cage_inner_radius,
height=cage_thickness,
bottom_z=cage_z_offset - cage_thickness / 2.0,
)
cage_rear = _ring_solid(
scad,
outer_radius=cage_outer_radius,
inner_radius=cage_inner_radius,
height=cage_thickness,
bottom_z=-cage_z_offset - cage_thickness / 2.0,
)
for subcomponent_id, item in [
(f"{component_id}_outer_ring", bearing_part(f"{component_id}_outer_ring", outer_ring, f"{component_id} outer ring")),
(f"{component_id}_inner_ring", bearing_part(f"{component_id}_inner_ring", inner_ring, f"{component_id} inner ring")),
(f"{component_id}_cage_front", bearing_part(f"{component_id}_cage_front", cage_front, f"{component_id} front cage")),
(f"{component_id}_cage_rear", bearing_part(f"{component_id}_cage_rear", cage_rear, f"{component_id} rear cage")),
]:
assembly = scad.add_component_rassembly(
assembly=assembly,
item=item,
component_id=subcomponent_id,
placement=identity,
name=subcomponent_id.replace("_", " ").title(),
)
ball = scad.make_sphere_rsolid(radius=ball_radius, center=(0.0, 0.0, 0.0))
ball = _apply_cascade_tags(
scad,
shape=ball,
instance=instance,
component_id=component_id,
formula_ref=f"cascade.{component_id}.rolling_ball",
)
ball_part = scad.make_part_rpart(part_id=f"{component_id}_ball", body=ball, name=f"{component_id} rolling ball")
ball_part = scad.assign_material_rpart(part=ball_part, material=material)
ball_component_ids: list[str] = []
digits = max(2, len(str(design.bearing_ball_count - 1)))
for index in range(design.bearing_ball_count):
angle_degrees = 360.0 * index / design.bearing_ball_count
angle = math.radians(angle_degrees)
ball_component_id = f"{component_id}_ball_{index:0{digits}d}"
ball_component_ids.append(ball_component_id)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=ball_part,
component_id=ball_component_id,
placement=scad.make_placement_rplacement(
origin=(ball_pitch_radius * math.cos(angle), ball_pitch_radius * math.sin(angle), 0.0),
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
),
name=f"{component_id} ball {index}",
)
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{component_id}_inner_outer_revolute",
connector_a=scad.make_connector_ref_rconnectorref(f"{component_id}_outer_ring", "axis"),
connector_b=scad.make_connector_ref_rconnectorref(f"{component_id}_inner_ring", "axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{component_id} inner ring spins in outer ring",
)
assembly = add_forwarded_connector(
scad,
assembly=assembly,
connector_id="outer_axis",
source_component_id=f"{component_id}_outer_ring",
source_connector_id="axis",
name=f"{component_id} outer ring public axis",
)
assembly = add_forwarded_connector(
scad,
assembly=assembly,
connector_id="inner_axis",
source_component_id=f"{component_id}_inner_ring",
source_connector_id="axis",
name=f"{component_id} inner ring public axis",
)
if hasattr(assembly, "set_metadata"):
assembly.set_metadata(
"planetary_reducer.cascade_interstage_bearing",
{
"component_id": component_id,
"bore_diameter_mm": design.bearing_inner_diameter_mm,
"outer_diameter_mm": design.bearing_outer_diameter_mm,
"width_mm": design.bearing_width_mm,
"ball_diameter_mm": design.bearing_ball_diameter_mm,
"ball_count": design.bearing_ball_count,
"ball_component_ids": ball_component_ids,
},
)
return assembly
def _placement_z_angle_degrees(placement: Any) -> float:
return math.degrees(math.atan2(float(placement.x_axis[1]), float(placement.x_axis[0])))
def _shape_in_world(scad: Any, *, shape: Any, placement: Any) -> Any:
angle = _placement_z_angle_degrees(placement)
rotated = scad.rotate_shape(
shape=shape,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
return scad.translate_shape(rotated, tuple(float(value) for value in placement.origin))
def _export_assembled_component_steps(scad: Any, *, assembly: Any, out_dir: Path) -> dict[str, str]:
component_dir = out_dir / "component_steps"
component_dir.mkdir(parents=True, exist_ok=True)
paths: dict[str, str] = {}
for component_id in assembly.component_ids():
component = assembly.get_component(component_id)
item = component.item
if hasattr(item, "body"):
shape = item.body
elif hasattr(item, "component_ids"):
shape = scad.make_compound_from_assembly_rcompound(assembly=item)
else:
continue
shape = _shape_in_world(scad, shape=shape, placement=component.placement)
path = component_dir / f"{component_id}.step"
scad.export_step(shapes=shape, filename=str(path))
paths[component_id] = str(path.resolve())
return paths
def _cref(scad: Any, *, component_id: str, connector_id: str) -> Any:
return scad.make_connector_ref_rconnectorref(component_id=component_id, connector_id=connector_id)
def _stage_parts(
scad: Any,
*,
contexts: list[StageBuildContext],
placement_plan: PlacementPlan,
materials: dict[str, Any],
) -> dict[str, Any]:
instance = contexts[0].instance
transmission_design = compute_cascade_interstage_design(instance)
s1_bore_radius = _bore_radius_for_sun(contexts[0])
s2_hub_radius = max(_bore_radius_for_sun(contexts[1]) + 1.1, contexts[1].derived.sun_pitch_radius_mm * 0.42)
parts: dict[str, Any] = {
"housing": make_cascade_housing(
scad,
contexts=contexts,
material=materials["housing"],
transmission_design=transmission_design,
),
"interstage_shaft": make_interstage_shaft(
scad,
instance=instance,
design=transmission_design,
material=materials["gear"],
),
"interstage_coupler": make_interstage_coupler(
scad,
instance=instance,
design=transmission_design,
material=materials["carrier"],
),
"cascade_input_shaft": make_cascade_port_shaft(
scad,
instance=instance,
placement_plan=placement_plan,
component_id="cascade_input_shaft",
radius_mm=max(0.55, min(1.70, s1_bore_radius * 0.72)),
material=materials["carrier"],
),
"cascade_output_shaft": make_cascade_port_shaft(
scad,
instance=instance,
placement_plan=placement_plan,
component_id="cascade_output_shaft",
radius_mm=max(0.80, min(2.6, s2_hub_radius * 0.45)),
material=materials["carrier"],
),
}
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
parts[component_id] = make_cascade_ball_bearing(
scad,
instance=instance,
design=transmission_design,
component_id=component_id,
material=materials["gear"],
)
for component_id in ["interstage_support_1", "interstage_support_2"]:
parts[component_id] = make_interstage_support(
scad,
instance=instance,
design=transmission_design,
component_id=component_id,
material=materials["housing"],
)
for ctx in contexts:
sid = ctx.stage.stage_id
parts[stage_component_id(sid, "sun")] = make_stage_sun(scad, ctx=ctx, material=materials["gear"])
parts[f"{sid}_planet"] = make_stage_planet(scad, ctx=ctx, material=materials["gear"])
parts[stage_component_id(sid, "ring")] = make_stage_ring(scad, ctx=ctx, material=materials["ring"])
parts[stage_component_id(sid, "carrier")] = make_stage_carrier(scad, ctx=ctx, material=materials["carrier"])
return parts
def _build_cascade_assembly(
scad: Any,
*,
instance: ReducerInstance,
contexts: list[StageBuildContext],
placement_plan: PlacementPlan,
parts: dict[str, Any],
) -> Any:
transmission_design = compute_cascade_interstage_design(instance)
assembly = scad.make_assembly_rassembly(
assembly_id=f"{instance.run_id}_simple_2kh_cascade_{instance.parameters.tooth_form}_reducer",
name=f"Formula-generated two-stage simple 2K-H {instance.parameters.tooth_form} cascade",
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts["housing"],
component_id="housing",
placement=scad.identity_placement_rplacement(),
name="Cascade housing/reference",
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts["interstage_shaft"],
component_id="interstage_shaft",
placement=scad.identity_placement_rplacement(),
name="Interstage shaft from stage 1 carrier to stage 2 sun",
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts["interstage_coupler"],
component_id="interstage_coupler",
placement=make_z_rotation_placement(
scad,
origin=(0.0, 0.0, transmission_design.coupler_center_z_mm),
angle_degrees=0.0,
),
name="Interstage coupling sleeve",
)
for component_id, center_z in transmission_design.bearing_centers_z_mm.items():
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts[component_id],
component_id=component_id,
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, center_z), angle_degrees=0.0),
name=component_id.replace("_", " ").title(),
)
for component_id, center_z in transmission_design.support_centers_z_mm.items():
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts[component_id],
component_id=component_id,
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, center_z), angle_degrees=0.0),
name=component_id.replace("_", " ").title(),
)
for ctx in contexts:
sid = ctx.stage.stage_id
for local in ["ring", "sun", "carrier"]:
cid = stage_component_id(sid, local)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts[cid],
component_id=cid,
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, ctx.z_offset_mm), angle_degrees=0.0),
name=f"{cid} {_component_role(instance, cid)}",
)
for index in range(ctx.stage.planet_count):
cid = f"{sid}_planet_{index + 1}"
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts[f"{sid}_planet"],
component_id=cid,
placement=_planet_placement(scad, ctx=ctx, index=index),
name=f"{sid} planet gear {index + 1}",
)
for ctx in contexts:
sid = ctx.stage.stage_id
stage_requirement = next(stage for stage in instance.requirement.stages or [] if stage.stage_id == sid)
if stage_requirement.fixed is None:
continue
fixed_component = stage_component_id(sid, stage_requirement.fixed)
assembly = scad.ground_component_rassembly(assembly=assembly, component_id=fixed_component)
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
connector_a=_cref(scad, component_id=fixed_component, connector_id="axis"),
connector_b=_cref(scad, component_id="housing", connector_id=f"{sid}_axis"),
name=f"{fixed_component} fixed to housing",
)
for local in ["sun", "ring", "carrier"]:
if local == stage_requirement.fixed:
continue
component = stage_component_id(sid, local)
role = _component_role(instance, component)
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{component}_{role}_revolute",
connector_a=_cref(scad, component_id=fixed_component, connector_id="axis"),
connector_b=_cref(scad, component_id=component, connector_id="axis"),
drive_angle_degrees=0.0 if component == instance.boundary.input else None,
angle_limit=None,
name=f"{component} {role} revolute",
)
for index in range(ctx.stage.planet_count):
planet_id = f"{sid}_planet_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{planet_id}_pin_revolute",
connector_a=_cref(scad, component_id=stage_component_id(sid, "carrier"), connector_id=f"{sid}_planet_{index + 1}_axis"),
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"{planet_id} pin bearing revolute",
)
assembly = scad.add_gear_constraint_rassembly(
assembly=assembly,
constraint_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
connector_a=_cref(scad, component_id=stage_component_id(sid, "sun"), connector_id="axis"),
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
pitch_radius_a=ctx.derived.sun_pitch_radius_mm,
pitch_radius_b=ctx.derived.planet_pitch_radius_mm,
phase_offset=None,
name=f"{sid} sun external mesh to planet {index + 1}",
)
assembly = scad.add_belt_constraint_rassembly(
assembly=assembly,
constraint_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
connector_a=_cref(scad, component_id=stage_component_id(sid, "ring"), connector_id="axis"),
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
pulley_radius_a=ctx.derived.ring_pitch_radius_mm,
pulley_radius_b=ctx.derived.planet_pitch_radius_mm,
phase_offset=None,
name=f"{sid} ring internal mesh to planet {index + 1}",
)
if instance.requirement.stages and len(instance.requirement.stages) == 2:
first, second = instance.requirement.stages
source = stage_component_id(first.stage_id, first.output)
target = stage_component_id(second.stage_id, second.input)
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{source}_rigid_to_interstage_shaft",
connector_a=_cref(scad, component_id=source, connector_id="coupling_axis"),
connector_b=_cref(scad, component_id="interstage_shaft", connector_id="s1_output_axis"),
name=f"{source} drives interstage shaft",
)
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"interstage_shaft_rigid_to_{target}",
connector_a=_cref(scad, component_id="interstage_shaft", connector_id="s2_input_axis"),
connector_b=_cref(scad, component_id=target, connector_id="axis"),
name=f"Interstage shaft drives {target}",
)
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id="interstage_coupler_fixed_to_interstage_shaft",
connector_a=_cref(scad, component_id="interstage_shaft", connector_id="coupler_axis"),
connector_b=_cref(scad, component_id="interstage_coupler", connector_id="axis"),
name="Interstage coupling sleeve fixed to shaft",
)
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{component_id}_outer_fixed_to_housing",
connector_a=_cref(scad, component_id="housing", connector_id=f"{component_id}_axis"),
connector_b=_cref(scad, component_id=component_id, connector_id="outer_axis"),
name=f"{component_id} outer ring supported by housing",
)
for component_id in ["interstage_support_1", "interstage_support_2"]:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{component_id}_bolted_to_housing",
connector_a=_cref(scad, component_id="housing", connector_id=f"{component_id}_axis"),
connector_b=_cref(scad, component_id=component_id, connector_id="axis"),
name=f"{component_id} bolted to common housing",
)
solved = scad.solve_assembly_constraints_rassembly(assembly=assembly, strict=True)
for component_id in ["cascade_input_shaft", "cascade_output_shaft"]:
pose = placement_plan.require_pose(component_id)
z0 = float(pose.details["z_span_mm"][0])
solved = scad.add_component_rassembly(
assembly=solved,
item=parts[component_id],
component_id=component_id,
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, z0), angle_degrees=0.0),
name=component_id.replace("_", " ").title(),
)
return solved
def _component_manifests(
instance: ReducerInstance,
contexts: list[StageBuildContext],
*,
placement_plan: PlacementPlan,
component_step_paths: dict[str, str] | None = None,
) -> dict[str, ComponentManifest]:
component_step_paths = component_step_paths or {}
transmission_design = compute_cascade_interstage_design(instance)
components: dict[str, ComponentManifest] = {
"housing": ComponentManifest(
component_id="housing",
formula_node="housing",
local_role="housing",
role="housing",
kind="housing",
center_xyz_mm=placement_plan.require_pose("housing").center_xyz_mm,
axis_xyz=placement_plan.require_pose("housing").axis_xyz,
tags=[f"formula.run_id.{instance.run_id}", "formula.node.housing"],
assembled_step_path=component_step_paths.get("housing"),
details={
"housing_inner_diameter_mm": transmission_design.housing_inner_diameter_mm,
"housing_outer_diameter_mm": transmission_design.housing_outer_diameter_mm,
"supports_interstage_bearings": ["interstage_bearing_1", "interstage_bearing_2"],
},
)
}
components["interstage_shaft"] = ComponentManifest(
component_id="interstage_shaft",
formula_node="cascade.interstage_shaft",
stage_id="cascade",
local_role="interstage_shaft",
role="interstage_power_transfer",
kind="shaft",
center_xyz_mm=placement_plan.require_pose("interstage_shaft").center_xyz_mm,
axis_xyz=placement_plan.require_pose("interstage_shaft").axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
"formula.node.interstage_shaft",
"formula.stage.cascade",
"formula.role.coupled",
],
assembled_step_path=component_step_paths.get("interstage_shaft"),
details={
"shaft_diameter_mm": transmission_design.shaft_diameter_mm,
"shaft_bottom_z_mm": transmission_design.shaft_bottom_z_mm,
"shaft_top_z_mm": transmission_design.shaft_top_z_mm,
"driven_by": transmission_design.source_component_id,
"drives": transmission_design.target_component_id,
},
)
for component_id, role, connects in [
("cascade_input_shaft", "input", [instance.boundary.input]),
("cascade_output_shaft", "output", [instance.boundary.output]),
]:
pose = placement_plan.require_pose(component_id)
z0, z1 = pose.details["z_span_mm"]
components[component_id] = ComponentManifest(
component_id=component_id,
formula_node=f"cascade.{component_id}",
stage_id="cascade",
local_role=component_id,
role=role,
kind="shaft",
center_xyz_mm=pose.center_xyz_mm,
axis_xyz=pose.axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
f"formula.node.{component_id}",
"formula.stage.cascade",
f"formula.role.{role}",
],
assembled_step_path=component_step_paths.get(component_id),
details={
"shaft_bottom_z_mm": z0,
"shaft_top_z_mm": z1,
"connects": connects,
},
)
components["interstage_coupler"] = ComponentManifest(
component_id="interstage_coupler",
formula_node="cascade.interstage_coupler",
stage_id="cascade",
local_role="interstage_coupler",
role="interstage_coupling",
kind="coupling",
center_xyz_mm=placement_plan.require_pose("interstage_coupler").center_xyz_mm,
axis_xyz=placement_plan.require_pose("interstage_coupler").axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
"formula.node.interstage_coupler",
"formula.stage.cascade",
],
assembled_step_path=component_step_paths.get("interstage_coupler"),
details={
"coupling_type": "keyed_sleeve_semantic",
"inner_diameter_mm": transmission_design.coupler_inner_diameter_mm,
"outer_diameter_mm": transmission_design.coupler_outer_diameter_mm,
"width_mm": transmission_design.coupler_width_mm,
"center_z_mm": transmission_design.coupler_center_z_mm,
},
)
for component_id, center_z in transmission_design.bearing_centers_z_mm.items():
components[component_id] = ComponentManifest(
component_id=component_id,
formula_node=f"cascade.{component_id}",
stage_id="cascade",
local_role="interstage_bearing",
role="interstage_shaft_support",
kind="bearing",
center_xyz_mm=placement_plan.require_pose(component_id).center_xyz_mm,
axis_xyz=placement_plan.require_pose(component_id).axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
f"formula.node.{component_id}",
"formula.stage.cascade",
],
assembled_step_path=component_step_paths.get(component_id),
details={
"bearing_type": "radial_ball_bearing",
"standard_series": "parametric_deep_groove",
"inner_diameter_mm": transmission_design.bearing_inner_diameter_mm,
"outer_diameter_mm": transmission_design.bearing_outer_diameter_mm,
"width_mm": transmission_design.bearing_width_mm,
"ball_diameter_mm": transmission_design.bearing_ball_diameter_mm,
"ball_count": transmission_design.bearing_ball_count,
"center_z_mm": center_z,
"supported_shaft": "interstage_shaft",
},
)
for component_id, center_z in transmission_design.support_centers_z_mm.items():
components[component_id] = ComponentManifest(
component_id=component_id,
formula_node=f"cascade.{component_id}",
stage_id="cascade",
local_role="interstage_support",
role="interstage_bearing_support",
kind="support",
center_xyz_mm=placement_plan.require_pose(component_id).center_xyz_mm,
axis_xyz=placement_plan.require_pose(component_id).axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
f"formula.node.{component_id}",
"formula.stage.cascade",
],
assembled_step_path=component_step_paths.get(component_id),
details={
"inner_diameter_mm": transmission_design.support_inner_diameter_mm,
"outer_diameter_mm": transmission_design.support_outer_diameter_mm,
"width_mm": transmission_design.support_width_mm,
"center_z_mm": center_z,
"bolted_to": "housing",
},
)
for ctx in contexts:
sid = ctx.stage.stage_id
for local in ["ring", "sun", "carrier"]:
cid = stage_component_id(sid, local)
spec = _gear_spec(ctx, local) if local in {"ring", "sun"} else None
pitch = {
"ring": ctx.derived.ring_pitch_radius_mm,
"sun": ctx.derived.sun_pitch_radius_mm,
}.get(local)
components[cid] = ComponentManifest(
component_id=cid,
formula_node=cid,
stage_id=sid,
local_role=local,
role=_component_role(instance, cid),
kind="carrier" if local == "carrier" else "gear",
gear_type="internal" if local == "ring" else ("external" if local == "sun" else None),
tooth_count=ctx.stage.z_ring if local == "ring" else (ctx.stage.z_sun if local == "sun" else None),
tooth_form=ctx.stage.tooth_form if local in {"ring", "sun"} else None,
helix_angle_deg=spec.helix_angle_deg if spec else None,
helix_hand=spec.helix_hand if spec else None,
module_mm=ctx.stage.module_mm if local in {"ring", "sun"} else None,
pressure_angle_deg=ctx.stage.pressure_angle_deg if local in {"ring", "sun"} else None,
face_width_mm=ctx.stage.face_width_mm if local in {"ring", "sun"} else None,
pitch_radius_mm=pitch,
center_xyz_mm=placement_plan.require_pose(cid).center_xyz_mm,
axis_xyz=placement_plan.require_pose(cid).axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
f"formula.node.{cid}",
f"formula.stage.{sid}",
f"formula.local_role.{local}",
f"formula.role.{_component_role(instance, cid)}",
],
assembled_step_path=component_step_paths.get(cid),
)
planet_spec = _gear_spec(ctx, "planet")
for index in range(ctx.stage.planet_count):
cid = f"{sid}_planet_{index + 1}"
pose = placement_plan.require_pose(cid)
components[cid] = ComponentManifest(
component_id=cid,
formula_node=f"{sid}_planet",
stage_id=sid,
local_role="planet",
role="planet",
kind="gear",
gear_type="external",
tooth_count=ctx.stage.z_planet,
tooth_form=ctx.stage.tooth_form,
helix_angle_deg=planet_spec.helix_angle_deg,
helix_hand=planet_spec.helix_hand,
module_mm=ctx.stage.module_mm,
pressure_angle_deg=ctx.stage.pressure_angle_deg,
face_width_mm=ctx.stage.face_width_mm,
pitch_radius_mm=ctx.derived.planet_pitch_radius_mm,
center_xyz_mm=pose.center_xyz_mm,
axis_xyz=pose.axis_xyz,
tags=[
f"formula.run_id.{instance.run_id}",
f"formula.node.{cid}",
f"formula.stage.{sid}",
"formula.local_role.planet",
],
assembled_step_path=component_step_paths.get(cid),
)
return components
def _constraint_manifests(instance: ReducerInstance) -> list[ConstraintManifest]:
constraints: list[ConstraintManifest] = []
if not instance.requirement.stages or not instance.parameters.stages:
return constraints
for stage_requirement in instance.requirement.stages:
sid = stage_requirement.stage_id
stage = stage_parameter_by_id(instance.parameters, sid)
if stage_requirement.fixed is not None:
fixed_component = stage_component_id(sid, stage_requirement.fixed)
constraints.append(
ConstraintManifest(
constraint_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
relation_type="fixed",
component_a=fixed_component,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id=sid,
relation_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
)
)
for local in ["sun", "ring", "carrier"]:
if local == stage_requirement.fixed:
continue
component = stage_component_id(sid, local)
constraints.append(
ConstraintManifest(
constraint_id=f"{component}_{_component_role(instance, component)}_revolute",
relation_type="revolute_joint",
component_a=fixed_component,
component_b=component,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
stage_id=sid,
)
)
for index in range(stage.planet_count):
planet_id = f"{sid}_planet_{index + 1}"
constraints.extend(
[
ConstraintManifest(
constraint_id=f"{planet_id}_pin_revolute",
relation_type="revolute_joint",
component_a=stage_component_id(sid, "carrier"),
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
stage_id=sid,
relation_id=f"{sid}_planet_{index + 1}_pin_revolute",
),
ConstraintManifest(
constraint_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
relation_type="external_mesh",
component_a=stage_component_id(sid, "sun"),
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="gear",
stage_id=sid,
relation_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
),
ConstraintManifest(
constraint_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
relation_type="internal_mesh",
component_a=stage_component_id(sid, "ring"),
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="belt",
stage_id=sid,
relation_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
),
]
)
first, second = instance.requirement.stages
source = stage_component_id(first.stage_id, first.output)
target = stage_component_id(second.stage_id, second.input)
constraints.extend(
[
ConstraintManifest(
constraint_id="cascade_input_shaft_rigid_to_s1_sun",
relation_type="rigid",
component_a="cascade_input_shaft",
component_b=stage_component_id(first.stage_id, first.input),
source="formula_semantic",
cad_constraint_kind=None,
stage_id="cascade",
relation_id="cascade_input_shaft_to_stage1_input",
),
ConstraintManifest(
constraint_id="s2_carrier_rigid_to_cascade_output_shaft",
relation_type="rigid",
component_a=stage_component_id(second.stage_id, second.output),
component_b="cascade_output_shaft",
source="formula_semantic",
cad_constraint_kind=None,
stage_id="cascade",
relation_id="cascade_stage2_output_to_output_shaft",
),
ConstraintManifest(
constraint_id=f"{source}_rigid_to_{target}",
relation_type="rigid",
component_a=source,
component_b=target,
source="formula_semantic",
cad_constraint_kind=None,
stage_id="cascade",
relation_id=f"{source}_rigid_to_{target}",
),
ConstraintManifest(
constraint_id=f"{source}_rigid_to_interstage_shaft",
relation_type="rigid",
component_a=source,
component_b="interstage_shaft",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id="cascade",
relation_id="cascade_stage1_output_to_interstage_shaft",
),
ConstraintManifest(
constraint_id=f"interstage_shaft_rigid_to_{target}",
relation_type="rigid",
component_a="interstage_shaft",
component_b=target,
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id="cascade",
relation_id="cascade_interstage_shaft_to_stage2_input",
),
ConstraintManifest(
constraint_id="interstage_coupler_fixed_to_interstage_shaft",
relation_type="rigid",
component_a="interstage_coupler",
component_b="interstage_shaft",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id="cascade",
relation_id="cascade_interstage_coupler_to_shaft",
),
ConstraintManifest(
constraint_id="interstage_shaft_supported_by_housing",
relation_type="bearing_support",
component_a="interstage_shaft",
component_b="housing",
source="formula_semantic",
cad_constraint_kind=None,
stage_id="cascade",
relation_id="interstage_shaft_supported_by_housing",
),
]
)
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
constraints.append(
ConstraintManifest(
constraint_id=f"{component_id}_outer_fixed_to_housing",
relation_type="bearing_support",
component_a=component_id,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id="cascade",
relation_id=f"{component_id}_outer_fixed_to_housing",
)
)
for component_id in ["interstage_support_1", "interstage_support_2"]:
constraints.append(
ConstraintManifest(
constraint_id=f"{component_id}_bolted_to_housing",
relation_type="bolted",
component_a=component_id,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
stage_id="cascade",
relation_id=f"{component_id}_bolted_to_housing",
)
)
return constraints
def generate_simplecad_cascade(instance: ReducerInstance, out_dir: Path) -> AssemblyManifest:
from .simplecad_generator import export_model_session_json, guard_no_gear_test, import_simplecadapi
guard_no_gear_test()
if instance.parameters.tooth_form not in {"spur", "helical"}:
raise RuntimeError(f"unsupported_tooth_form: {instance.parameters.tooth_form}")
scad = import_simplecadapi()
out_dir.mkdir(parents=True, exist_ok=True)
step_path = out_dir / "reducer.step"
model_path = out_dir / "reducer.model.json"
session_path = out_dir / "reducer.session.json"
manifest_path = out_dir / "assembly_manifest.json"
build_meta_path = out_dir / "build_meta.json"
placement_plan_path = out_dir / "placement_plan.json"
graph_id = f"{instance.run_id}_simple_2kh_cascade_{instance.parameters.tooth_form}_generation"
contexts = _stage_contexts(instance)
transmission_design = compute_cascade_interstage_design(instance)
placement_plan = solve_reducer_placements(instance)
with scad.GraphSession(graph_id=graph_id) as session:
materials = make_materials(scad)
parts = _stage_parts(scad, contexts=contexts, placement_plan=placement_plan, materials=materials)
assembly = _build_cascade_assembly(
scad,
instance=instance,
contexts=contexts,
placement_plan=placement_plan,
parts=parts,
)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
component_step_paths = _export_assembled_component_steps(scad, assembly=assembly, out_dir=out_dir)
model_json, session_json = export_model_session_json(scad, session=session)
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))
write_model_json(placement_plan_path, placement_plan)
model_payload = json.loads(model_json)
manifest = AssemblyManifest(
run_id=instance.run_id,
formula_instance_hash=sha256_json(instance.model_dump(mode="json")),
generated_at=utc_now_iso(),
generator=f"simplecadapi_{instance.parameters.tooth_form}_simple_2kh_cascade_v0.1.0",
components=_component_manifests(instance, contexts, placement_plan=placement_plan, component_step_paths=component_step_paths),
constraints=_constraint_manifests(instance),
exported_files={
"step": str(step_path.resolve()),
"model": str(model_path.resolve()),
"session": str(session_path.resolve()),
"manifest": str(manifest_path.resolve()),
"build_meta": str(build_meta_path.resolve()),
"component_steps_dir": str((out_dir / "component_steps").resolve()),
"placement_plan": str(placement_plan_path.resolve()),
},
geometry_summary={
"preview_solids": len(preview.get_solids()),
"preview_volume": float(preview.get_volume()),
"model_graph_nodes": len(model_payload.get("graph", {}).get("nodes", [])),
"assembled_component_step_count": len(component_step_paths),
"stage_count": len(contexts),
"stage_gap_mm": float((instance.derived.axial_stack or {}).get("stage_gap_mm", 2.0)),
"interstage_transmission": {
"source_component_id": transmission_design.source_component_id,
"target_component_id": transmission_design.target_component_id,
"shaft_diameter_mm": transmission_design.shaft_diameter_mm,
"bearing_inner_diameter_mm": transmission_design.bearing_inner_diameter_mm,
"bearing_outer_diameter_mm": transmission_design.bearing_outer_diameter_mm,
"bearing_width_mm": transmission_design.bearing_width_mm,
"bearing_ball_diameter_mm": transmission_design.bearing_ball_diameter_mm,
"bearing_ball_count": transmission_design.bearing_ball_count,
"bearing_centers_z_mm": transmission_design.bearing_centers_z_mm,
"support_centers_z_mm": transmission_design.support_centers_z_mm,
},
"tooth_form": instance.parameters.tooth_form,
"helix_angle_deg": instance.parameters.helix_angle_deg,
"helix_hand": instance.parameters.helix_hand,
"placement_solver": placement_plan.solver_version,
"stage_phase_policy": {
ctx.stage.stage_id: {
"internal_ring_phase_correction_deg": internal_ring_phase_correction_degrees(
z_planet=ctx.stage.z_planet,
z_ring=ctx.stage.z_ring,
),
"planet_phase_formula": "180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)",
}
for ctx in contexts
},
},
)
write_model_json(manifest_path, manifest)
build_meta = {
"run_id": instance.run_id,
"formula_instance_hash": manifest.formula_instance_hash,
"generator": manifest.generator,
"simplecadapi_file": getattr(scad, "__file__", "unknown"),
"graph_id": graph_id,
"topology_family": instance.topology_family,
"boundary": instance.boundary.model_dump(mode="json"),
"axial_stack": instance.derived.axial_stack,
"assembly_id": getattr(assembly, "assembly_id", None),
"component_ids": assembly.component_ids(),
"constraint_ids": assembly.constraint_ids(),
"preview_solids": manifest.geometry_summary["preview_solids"],
"preview_volume": manifest.geometry_summary["preview_volume"],
"model_graph_nodes": manifest.geometry_summary["model_graph_nodes"],
"stage_phase_policy": manifest.geometry_summary["stage_phase_policy"],
"placement_plan_hash": sha256_json(placement_plan.model_dump(mode="json")),
"exported_files": manifest.exported_files,
"file_mtime_ns": {
"step": step_path.stat().st_mtime_ns,
"model": model_path.stat().st_mtime_ns,
"session": session_path.stat().st_mtime_ns,
"placement_plan": placement_plan_path.stat().st_mtime_ns,
},
"generated_at": utc_now_iso(),
}
write_json(build_meta_path, build_meta)
return manifest