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