diff --git a/.gitignore b/.gitignore index 97191c0..fb2bcaf 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,12 @@ gemini_venv/ # ignore test python scripts in root /*.py +# ignore the projects directory (for now) +projects/* + +# ignore output directories +output/* +examples/output/* + +# ignore generated PDF files in docs +docs/*.pdf diff --git a/docs/manufacturing_postprocessing.rst b/docs/manufacturing_postprocessing.rst new file mode 100644 index 0000000..0675616 --- /dev/null +++ b/docs/manufacturing_postprocessing.rst @@ -0,0 +1,787 @@ +Manufacturing Post-Processing Framework +====================================== + +Design Document v0.2 - January 2026 + +.. note:: + + **Implementation Status: Phase 1 Complete** + + The core beam segmentation functionality is implemented and working: + + - Swept element segmentation at specified cut points + - Interior connector specification generation + - STEP file export for individual segments + - Assembly manifest generation with mating relationships + + See ``examples/globe_stand/segment_globe_stand.py`` for a working example. + +Quick Start +----------- + +Run the globe stand segmentation example: + +.. code-block:: bash + + # Segment support arcs at 200mm max length, export STEP files + python examples/globe_stand/segment_globe_stand.py + + # Segment at 150mm max length + python examples/globe_stand/segment_globe_stand.py 150 + + # Skip STEP export (faster for testing) + python examples/globe_stand/segment_globe_stand.py --no-step + +This produces: + +- Individual STEP files for each segment (e.g., ``support_arc_1_seg_0.step``) +- ``assembly_manifest.json`` with part relationships and file paths + +Basic Python usage: + +.. code-block:: python + + from yapcad.manufacturing import ( + SweptElementProvenance, + CutPoint, + segment_swept_element, + compute_optimal_cuts, + ) + + # Create provenance for your swept solid + provenance = SweptElementProvenance( + id="my_beam", + operation="sweep_adaptive", + outer_profile=outer_region2d, + spine=path3d, + wall_thickness=2.0, + metadata={'solid': my_solid} # The actual solid + ) + + # Compute cuts for max 200mm segments + cuts = compute_optimal_cuts(provenance, max_segment_length=200.0) + + # Segment the solid + result = segment_swept_element(provenance, cuts) + print(f"Created {result.segment_count} segments") + + +Overview +-------- + +This document specifies a post-processing framework for yapCAD that enables +manufacturing of parts that exceed machine capabilities (build volume, reach, etc.) +by intelligently splitting monolithic designs into assembleable sub-parts. + +The initial focus is on **beam segmentation** - splitting hollow swept structures +(box beams, tubes, channels) into printable segments with integrated connectors. +This framework is designed to be extensible to other manufacturing post-processing +tasks including custom toolpath generation. + +Problem Statement +----------------- + +**Use Case: Large 3D Printed Parts** + +A designer creates a parametric model (e.g., a globe stand) that exceeds the +build volume of their printer. The model consists of structural elements that +are hollow box-beam or tube sections swept along 2D or 3D curves. + +Current solutions: + +1. **Manual splitting in CAD** - Time-consuming, error-prone, loses parametric benefits +2. **Mesh-based slicing tools** - Lose semantic understanding, can't create smart connectors +3. **Scaling down** - Compromises design intent + +Desired solution: + +- Automated or semi-automated splitting at semantically meaningful locations +- Connectors that maintain structural integrity +- Parametric - regenerates correctly when source design changes +- Works with arbitrary curved swept geometry + +Terminology +----------- + +**Swept Element** + A solid created by sweeping a 2D profile along a 2D or 3D path (spine). + Includes box beams, tubes, channels, and other hollow or solid extrusions. + +**Segment** + A portion of a swept element between two cut planes. + +**Cut Point** + A location along a swept element's spine where a segmentation cut is made. + Defined by a parameter t in [0, 1] along the spine. + +**Cut Plane** + The plane perpendicular to the spine tangent at a cut point. + +**Interior Connector** + A solid that fits inside the hollow interior of a swept element, used to + join two segments. Follows the same spine curve as the parent element. + +**Connector Tab** + An interior connector that is unioned with one segment, creating a + male-female assembly interface. + +**Fit Clearance** + The dimensional offset applied to connector cross-sections to achieve + the desired fit (press-fit, slip-fit, etc.). + +**Provenance Metadata** + Information retained from the original modeling operations that describes + how geometry was created (profiles, spines, sweep parameters, etc.). + + +Approach: Beam Segmentation with Interior Connectors +---------------------------------------------------- + +Core Algorithm +^^^^^^^^^^^^^^ + +For a hollow swept element with known profile and spine: + +1. **Identify cut points** along the spine (user-specified or computed) + +2. **For each cut point:** + + a. Compute the cut plane (perpendicular to spine tangent) + + b. Split the swept solid into two segments at this plane + + c. Create an interior connector: + + - Extract the interior void profile (or compute from outer profile - wall thickness) + - Apply fit clearance (shrink profile slightly) + - Determine connector length based on profile size and curvature + - Sweep the connector profile along the spine segment centered on cut point + + d. Union the connector with one segment (creating "male" side) + + e. The other segment becomes the "female" side + +3. **Validate** that resulting segments fit within target build volume + +4. **Generate** assembly instructions (which connectors mate with which segments) + + +Connector Design Details +^^^^^^^^^^^^^^^^^^^^^^^^ + +**Cross-Section Derivation** + +For a box beam with outer profile ``[w, h]`` and wall thickness ``t``: + +- Outer profile: rectangle ``w × h`` +- Inner void: rectangle ``(w - 2t) × (h - 2t)`` +- Connector profile: rectangle ``(w - 2t - 2c) × (h - 2t - 2c)`` where ``c`` is fit clearance + +For profiles with fillets/chamfers, the connector profile should preserve these +features at the reduced scale for proper load distribution. + +**Connector Length** + +Minimum connector length ``L_min``: + +- Straight sections: ``L_min = 3 × max(w, h)`` (3x largest profile dimension) +- Curved sections: ``L_min = max(3 × max(w, h), arc_length_for_15_degrees)`` + +The connector extends equally on both sides of the cut plane. + +**Fit Clearance Values** (FDM printing defaults) + +- Press-fit (structural): 0.15 - 0.20 mm per side +- Slip-fit (easy assembly): 0.25 - 0.35 mm per side +- Loose-fit (adjustable): 0.40 - 0.50 mm per side + +These values are material and printer dependent; should be configurable. + +**Curved Section Handling** + +For curved spines, the connector must follow the same curve. This is handled +naturally by sweeping the connector profile along the appropriate segment of +the original spine. The sweep operation preserves curve fidelity. + +For tight curves (radius < 5x profile dimension), consider: + +- Longer connectors to span more of the curve +- Warning user about potential stress concentrations +- Suggesting alternative cut points + + +Provenance Requirements +----------------------- + +Effective beam segmentation requires knowing how geometry was created. + +**Required Provenance Data** + +For each swept element, retain: + +.. code-block:: yaml + + swept_element: + id: "arc_beam_1" + operation: "sweep" | "sweep_adaptive" | "sweep_hollow" + outer_profile: + inner_profile: + spine: + wall_thickness: + metadata: + semantic_type: "structural_beam" | "decorative" | "functional" + material_hint: "PLA" | "PETG" | "ABS" | etc. + +**DSL Integration** + +The DSL ``emit`` statement should capture sweep provenance automatically when +emitting results of sweep operations. Enhanced emit: + +.. code-block:: python + + # Current + emit result + + # Enhanced with explicit tagging + emit result, type="structural_beam", splittable=true + + # Or inferred from operation + result = sweep_hollow(outer, inner, spine) + emit result # Automatically tagged as swept_element + + +API Design +---------- + +Core Functions +^^^^^^^^^^^^^^ + +.. code-block:: python + + from yapcad.manufacturing import ( + identify_swept_elements, + compute_cut_points, + segment_swept_element, + create_interior_connector, + validate_build_volume, + generate_assembly_instructions, + ) + + # High-level workflow + def segment_for_printing( + solid, + build_volume: tuple[float, float, float], + *, + fit_clearance: float = 0.2, + connector_length_factor: float = 3.0, + cut_points: list[CutPoint] = None, # None = auto-compute + provenance: dict = None, # From DSL execution + ) -> SegmentationResult: + """ + Segment a solid for printing within specified build volume. + + Returns SegmentationResult containing: + - segments: list of segment solids + - connectors: list of connector solids (unioned with segments) + - assembly_graph: how segments connect + - warnings: any issues detected + """ + + # Lower-level functions for manual control + def segment_swept_element( + solid, + profile: Region2D, + spine: Path3D, + cut_parameter: float, # t in [0, 1] + *, + wall_thickness: float = None, + fit_clearance: float = 0.2, + connector_length: float = None, # Auto-compute if None + ) -> tuple[Solid, Solid, Solid]: + """ + Segment a swept element at the specified parameter. + + Returns (segment_a, segment_b, connector) + where connector is a separate solid (not yet unioned). + """ + + def create_interior_connector( + outer_profile: Region2D, + spine: Path3D, + center_parameter: float, + length: float, + *, + wall_thickness: float = None, + inner_profile: Region2D = None, + fit_clearance: float = 0.2, + ) -> Solid: + """ + Create an interior connector solid. + + If inner_profile is provided, uses it directly. + Otherwise, derives from outer_profile and wall_thickness. + """ + + +Data Structures +^^^^^^^^^^^^^^^ + +.. code-block:: python + + @dataclass + class CutPoint: + """Specification for a segmentation cut.""" + element_id: str # ID of swept element to cut + parameter: float # t in [0, 1] along spine + connector_length: float = None # Override auto-computed length + fit_clearance: float = 0.2 + union_connector_with: str = "a" # "a", "b", or "none" + + @dataclass + class Segment: + """A segment resulting from splitting.""" + id: str + solid: Any # yapCAD solid + parent_element_id: str + parameter_range: tuple[float, float] # (t_start, t_end) + has_connector_tab: bool + mates_with: list[str] # IDs of segments this connects to + bounding_box: tuple # For build volume validation + + @dataclass + class SegmentationResult: + """Complete result of segmentation operation.""" + segments: list[Segment] + assembly_graph: dict # segment_id -> list of mating segment_ids + build_volume_ok: bool + warnings: list[str] + assembly_instructions: str # Human-readable + + +User Interaction Model +---------------------- + +Three levels of automation: + +**Level 1: Fully Automatic** + +.. code-block:: python + + result = segment_for_printing( + globe_stand_solid, + build_volume=(256, 256, 256), # Bambu X1C + provenance=execution_result.provenance, + ) + + # System automatically: + # - Identifies swept elements from provenance + # - Computes optimal cut points to fit build volume + # - Creates connectors and assembly plan + +**Level 2: Semi-Automatic (Guided)** + +.. code-block:: python + + # User identifies which elements to split + cut_points = [ + CutPoint("arc_beam_1", parameter=0.33), + CutPoint("arc_beam_1", parameter=0.67), + CutPoint("base_ring", parameter=0.25), + CutPoint("base_ring", parameter=0.50), + CutPoint("base_ring", parameter=0.75), + ] + + result = segment_for_printing( + globe_stand_solid, + build_volume=(256, 256, 256), + cut_points=cut_points, + provenance=execution_result.provenance, + ) + +**Level 3: Interactive (Future)** + +A visual tool that: + +- Displays the model with swept elements highlighted +- Shows build volume overlay +- Lets user click to place cut points +- Shows real-time preview of resulting segments +- Warns about problematic cuts (stress concentrations, tight curves) + + +DSL Integration +--------------- + +New DSL Commands +^^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Explicit segmentation in DSL + command PRINTABLE_GLOBE_STAND(build_x: float, build_y: float, build_z: float) -> list: + stand: solid = CENTERED_GLOBE_STAND_HIRES() + + # Automatic segmentation + segments: list = segment_for_build_volume( + stand, + build_x, build_y, build_z, + fit_clearance=0.2 + ) + + emit segments + + # Or with explicit cut points + command SEGMENTED_STAND() -> list: + # Build the stand components with provenance + arc1: solid = sweep_hollow(arc_profile, arc1_path, wall=3.0) + arc2: solid = sweep_hollow(arc_profile, arc2_path, wall=3.0) + arc3: solid = sweep_hollow(arc_profile, arc3_path, wall=3.0) + base: solid = sweep_hollow(base_profile, base_path, wall=3.0) + + # Segment each arc into 3 pieces + arc1_segments: list = segment_beam(arc1, [0.33, 0.67]) + arc2_segments: list = segment_beam(arc2, [0.33, 0.67]) + arc3_segments: list = segment_beam(arc3, [0.33, 0.67]) + + # Segment base ring into 4 pieces + base_segments: list = segment_beam(base, [0.25, 0.5, 0.75]) + + emit concat(arc1_segments, arc2_segments, arc3_segments, base_segments) + + +CLI Integration +^^^^^^^^^^^^^^^ + +.. code-block:: bash + + # Segment for specific printer + python -m yapcad.dsl run design.dsl MAKE_PART \ + --segment-for-printer "bambu_x1c" \ + --output segments/ + + # Segment with custom build volume + python -m yapcad.dsl run design.dsl MAKE_PART \ + --segment-build-volume 256,256,256 \ + --segment-clearance 0.2 \ + --output segments/ + + # Export as assembly package + python -m yapcad.dsl run design.dsl MAKE_PART \ + --segment-for-printer "bambu_x1c" \ + --package output.ycpkg \ + --include-assembly-instructions + + +Implementation Phases +--------------------- + +Phase 1: Core Segmentation (MVP) - **COMPLETE** +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Enable manual segmentation of swept elements with interior connectors. + +**Deliverables:** + +1. ✅ ``segment_swept_element()`` function - segments solids at cut planes +2. ✅ ``create_interior_connector()`` function - generates connector geometry +3. ✅ ``compute_optimal_cuts()`` function - computes cuts for max segment length +4. ✅ Basic provenance capture via ``SweptElementProvenance`` dataclass +5. ✅ Export segments as separate STEP files +6. ✅ Assembly manifest generation with mating relationships + +**Implementation Location:** ``src/yapcad/manufacturing/`` + +- ``data.py`` - Data structures (CutPoint, Segment, SegmentationResult, etc.) +- ``path_utils.py`` - Path3D evaluation and sub-path extraction +- ``connectors.py`` - Interior connector generation +- ``segmentation.py`` - Core segmentation operations + +**Example:** ``examples/globe_stand/segment_globe_stand.py`` + +**Scope:** + +- Box beam (rectangular) profiles ✅ +- Piecewise-linear (polyline) spines ✅ +- Manual cut point specification ✅ +- Automatic cut computation based on max length ✅ +- Single wall thickness ✅ +- STEP file export ✅ +- Interior connector solid generation ✅ + + +Phase 2: Automatic Cut Point Computation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Automatically determine optimal cut points for a target build volume. + +**Deliverables:** + +1. Build volume analysis +2. Optimal cut point algorithm (minimize cuts while fitting volume) +3. Cut point validation (avoid stress concentrations) +4. Assembly graph generation + +**Scope:** + +- Multi-element assemblies +- Constraint satisfaction (all segments fit build volume) +- Basic optimization (minimize number of cuts) + +**Estimated complexity:** Medium-High + + +Phase 3: Advanced Profiles and DSL Integration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Support arbitrary profiles and integrate with DSL workflow. + +**Deliverables:** + +1. Arbitrary profile support (circles, complex polygons) +2. DSL builtins for segmentation +3. Enhanced provenance from DSL execution +4. Assembly instruction generation + +**Scope:** + +- Any Region2D profile +- Profiles with fillets/chamfers +- Wall thickness variations +- DSL-native workflow + +**Estimated complexity:** Medium + + +Phase 4: Interactive Tools +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Visual tools for guided segmentation. + +**Deliverables:** + +1. Viewer integration showing swept elements +2. Interactive cut point placement +3. Real-time segment preview +4. Build volume visualization + +**Scope:** + +- Integration with existing yapCAD viewer +- Mouse-based cut point placement +- Visual feedback for fit validation + +**Estimated complexity:** High + + +Phase 5: Advanced Manufacturing Support +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Extend framework to other manufacturing processes. + +**Potential features:** + +- **CNC segmentation:** Split for maximum machinable pocket depth +- **Sheet metal nesting:** Optimize 2D layout for laser/waterjet +- **Multi-material assembly:** Segment by material requirements +- **Toolpath generation:** Custom G-code for specific machines +- **Support structure optimization:** Modify geometry for better printability + +**Estimated complexity:** Variable (feature-dependent) + + +Globe Stand Example +------------------- + +The ``examples/globe_stand/segment_globe_stand.py`` script demonstrates the +manufacturing module by segmenting the support arcs from the Mars globe stand. + +**Running the Example:** + +.. code-block:: bash + + cd yapCAD + PYTHONPATH=./src python examples/globe_stand/segment_globe_stand.py + +**Example Output:** + +.. code-block:: text + + ============================================================ + Globe Stand Segmentation Example + ============================================================ + + Parameters: + Globe diameter: 304.8 mm + Base diameter: 400.0 mm + Beam profile: 10.0x10.0 mm, wall: 2.0 mm + Max segment length: 200.0 mm + STEP export: True + + Building and segmenting support arcs... + ---------------------------------------- + + Arc 0: base=0.0°, top=60.0° + Path length: 460.6 mm + Cuts needed: 2 + Cut 0: t=0.333 + Cut 1: t=0.667 + Created 3 segments + Exported: support_arc_0_seg_0.step + Exported: support_arc_0_seg_1.step + Exported: support_arc_0_seg_2.step + Building connector solids... + Exported: support_arc_0_conn_33.step + Exported: support_arc_0_conn_66.step + + Arc 1: base=120.0°, top=180.0° + Path length: 396.1 mm + Cuts needed: 1 + Cut 0: t=0.500 + Created 2 segments + Exported: support_arc_1_seg_0.step + Exported: support_arc_1_seg_1.step + Building connector solids... + Exported: support_arc_1_conn_50.step + + Arc 2: base=240.0°, top=300.0° + Path length: 375.4 mm + Cuts needed: 1 + Cut 0: t=0.500 + Created 2 segments + Exported: support_arc_2_seg_0.step + Exported: support_arc_2_seg_1.step + Building connector solids... + Exported: support_arc_2_conn_50.step + + ============================================================ + Summary + ============================================================ + Total segments: 7 + Total connectors: 4 + STEP files exported: 11 + Output directory: examples/globe_stand/output + +**Output Files:** + +The script creates an ``output/`` directory containing: + +- ``assembly_manifest.json`` - Part relationships and file paths +- ``support_arc_0_seg_0.step`` - First third of arc 0 +- ``support_arc_0_seg_1.step`` - Middle third of arc 0 +- ``support_arc_0_seg_2.step`` - Last third of arc 0 +- ``support_arc_0_conn_33.step`` - Connector at first cut of arc 0 +- ``support_arc_0_conn_66.step`` - Connector at second cut of arc 0 +- ``support_arc_1_seg_0.step`` - First half of arc 1 +- ``support_arc_1_seg_1.step`` - Second half of arc 1 +- ``support_arc_1_conn_50.step`` - Connector at cut of arc 1 +- ``support_arc_2_seg_0.step`` - First half of arc 2 +- ``support_arc_2_seg_1.step`` - Second half of arc 2 +- ``support_arc_2_conn_50.step`` - Connector at cut of arc 2 + +**Assembly Manifest Format:** + +.. code-block:: json + + { + "project": "Mars Globe Stand", + "max_segment_length": 200.0, + "beam_profile": "10.0x10.0mm hollow, 2.0mm wall", + "fit_clearance": 0.18, + "parts": [ + { + "id": "support_arc_1_seg_0", + "type": "arc_segment", + "parent": "support_arc_1", + "parameter_range": [0.0, 0.5], + "mates_with": ["support_arc_1_seg_1"], + "has_connector_tab": true, + "step_file": "support_arc_1_seg_0.step" + }, + ... + ] + } + +**Connector Design:** + +- Connector length: 3x largest profile dimension (30mm for 10x10mm beam) +- Press-fit clearance: 0.18mm per side (default for FDM printing) + + +Future Extensions +----------------- + +**Toolpath Generation Framework** + +Beyond segmentation, yapCAD could generate custom toolpaths for: + +1. **Multi-axis CNC:** + - Continuous 5-axis paths for complex surfaces + - Adaptive clearing strategies + - Tool change optimization + +2. **Wire EDM:** + - 2D profile extraction for wire paths + - Taper angle computation + - Start hole placement + +3. **Additive manufacturing:** + - Non-planar slicing for improved surface quality + - Variable layer height based on geometry + - Support structure generation with easy removal + +4. **Hybrid manufacturing:** + - Combined additive + subtractive strategies + - Near-net-shape printing with finish machining + - Selective surface finishing + +**Machine Definition Framework** + +.. code-block:: yaml + + machine: + name: "Bambu Lab X1C" + type: "fdm_printer" + build_volume: + x: 256 + y: 256 + z: 256 + materials: + - PLA + - PETG + - ABS + tolerances: + xy_accuracy: 0.1 + z_accuracy: 0.05 + recommended_clearance: 0.2 + +This would enable printer-aware design validation and automatic segmentation +configuration. + + +Open Questions +-------------- + +1. **Connector orientation:** Should connectors always union with the "lower" + segment (easier printing) or should user choose? + +2. **Multi-wall beams:** How to handle beams with internal ribs or complex + internal geometry? + +3. **Junction handling:** How to segment at junctions where multiple beams meet? + +4. **Validation depth:** How much structural analysis (FEA) should inform + cut point selection? + +5. **Assembly aids:** Should we generate alignment features (pins, keys) in + addition to the main connector? + + +References +---------- + +- Manufacturing module: ``src/yapcad/manufacturing/`` +- yapCAD sweep operations: ``src/yapcad/geom3d_util.py`` +- BREP utilities: ``src/yapcad/brep.py`` +- Globe stand DSL source: ``examples/globe_stand/globe_stand_v5.dsl`` +- Globe stand segmentation example: ``examples/globe_stand/segment_globe_stand.py`` +- Package specification: ``docs/ycpkg_spec.rst`` diff --git a/examples/globe_stand/globe_stand_v5.dsl b/examples/globe_stand/globe_stand_v5.dsl index f270a60..adad3ff 100644 --- a/examples/globe_stand/globe_stand_v5.dsl +++ b/examples/globe_stand/globe_stand_v5.dsl @@ -452,7 +452,8 @@ command CENTERED_GLOBE_STAND_HIRES( beam_outer: float = 10.0, beam_wall: float = 2.0, angle_threshold: float = 5.0, - mars_oblateness: float = 0.00648 + mars_oblateness: float = 0.00648, + include_mars: bool = false ) -> solid: base_radius: float = base_diameter / 2.0 globe_radius: float = globe_diameter / 2.0 @@ -516,6 +517,6 @@ command CENTERED_GLOBE_STAND_HIRES( mars: solid = centered_mars_globe(globe_diameter, globe_center_z, tilt_angle, mars_oblateness) stand: solid = union(base, cradle, arc1, arc2, arc3) - result: solid = compound(stand, mars) + result: solid = compound(stand, mars) if include_mars else stand emit result diff --git a/examples/globe_stand/segment_globe_stand.py b/examples/globe_stand/segment_globe_stand.py new file mode 100644 index 0000000..1512a5c --- /dev/null +++ b/examples/globe_stand/segment_globe_stand.py @@ -0,0 +1,1387 @@ +#!/usr/bin/env python3 +"""Globe Stand Segmentation Example + +This script demonstrates the yapCAD manufacturing post-processing module by +segmenting the support arcs from the Mars globe stand design into printable +pieces with interior connectors for reassembly. + +The globe stand has three support arcs that span ~300-450mm in arc length. +For 3D printers with limited build volume (e.g., Bambu X1C: 256mm), these +arcs need to be segmented into smaller pieces. The manufacturing module: + +1. Analyzes swept element geometry and provenance +2. Computes optimal cut locations based on max segment length +3. Splits solids at cut planes using OCC boolean operations +4. Generates interior connectors that follow the original spine curve +5. Exports all geometry as STEP files for CAM/slicing software + +Usage: + python segment_globe_stand.py [OPTIONS] [max_segment_length] + +Options: + --no-step Skip STEP file export + --package Create yapCAD package with assembled visualization + --help, -h Show help message + +Arguments: + max_segment_length: Maximum length of each segment in mm (default: 200) + +Output: + Creates output/ directory with: + - Individual STEP files for each segment and connector + - assembly_manifest.json with part relationships and file paths + - Assembly instructions printed to console + - Optional: assembled.ycpkg package for visualization + +Example: + python segment_globe_stand.py 150 # Segment at 150mm max length + python segment_globe_stand.py --no-step # Skip STEP export for testing + python segment_globe_stand.py --package # Create assembled visualization package +""" + +import json +import math +import os +import sys +from pathlib import Path + +# Add src to path for development +sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) + +from yapcad.geom import line, point +from yapcad.geom3d_util import sweep_adaptive +from yapcad.manufacturing import ( + SweptElementProvenance, + CutPoint, + compute_optimal_cuts, + segment_swept_element, + segment_closed_ring, + build_connector_solids, + path_length, + FIT_CLEARANCE, + # Terminal connector functions + create_terminal_connector, + add_terminal_connectors_to_segment, + # Ring functions + create_ring_solid, + compute_arc_attachment_point, + add_female_holes_to_ring, + trim_segment_against_ring, + compute_ring_cuts_avoiding_holes, +) +from yapcad.geom3d import solid_boolean +from yapcad.brep import brep_from_solid +from yapcad.package import create_package_from_entities +from yapcad.metadata import get_solid_metadata + + +def export_solid_to_step(solid, filepath: str) -> bool: + """Export a yapCAD solid to a STEP file. + + Args: + solid: yapCAD solid with BREP data + filepath: Output STEP file path + + Returns: + True if export succeeded, False otherwise + """ + try: + from OCC.Core.STEPControl import STEPControl_Writer, STEPControl_AsIs + from OCC.Core.IFSelect import IFSelect_RetDone + + brep = brep_from_solid(solid) + if brep is None or brep.shape is None: + print(f" Warning: No BREP data for export to {filepath}") + return False + + # Create STEP writer + writer = STEPControl_Writer() + writer.Transfer(brep.shape, STEPControl_AsIs) + + # Write file + status = writer.Write(filepath) + if status == IFSelect_RetDone: + return True + else: + print(f" Warning: STEP write failed for {filepath}") + return False + + except ImportError as e: + print(f" Warning: OCC not available for STEP export: {e}") + return False + except Exception as e: + print(f" Warning: STEP export failed: {e}") + return False + + +def create_assembled_package( + all_solids: list, + output_path: Path, + max_segment_length: float, +) -> bool: + """Create a yapCAD package with all segments and connectors in assembled position. + + Args: + all_solids: List of (id, solid, part_type) tuples + output_path: Directory containing output files + max_segment_length: Max segment length used for segmentation + + Returns: + True if package was created successfully + """ + if not all_solids: + print(" No solids available for package creation") + return False + + # Collect solids and tag them with metadata + entities = [] + for part_id, solid, part_type in all_solids: + if solid is not None: + # Update metadata for the solid (preserve existing BREP data) + meta = get_solid_metadata(solid, create=True) + meta['name'] = part_id + meta['tags'] = [part_type] + entities.append(solid) + + if not entities: + print(" No valid solids for package creation") + return False + + package_path = output_path / "assembled.ycpkg" + + try: + manifest = create_package_from_entities( + entities, + package_path, + name="Mars Globe Stand - Segmented Assembly", + version="1.0.0", + description=f"Segmented globe stand support arcs with {max_segment_length}mm max segment length. " + f"Contains {len(entities)} parts (segments + connectors) in assembled position.", + units="mm", + overwrite=True, + ) + print(f"\n Created assembly package: {package_path}") + print(f" Parts included: {len(entities)}") + return True + except Exception as e: + print(f" Warning: Package creation failed: {e}") + return False + + +# ============================================================================ +# Globe Stand Parameters (from globe_stand_v5.dsl) +# ============================================================================ + +GLOBE_DIAMETER = 304.8 # 12 inches in mm +BASE_DIAMETER = 400.0 +TILT_ANGLE = 25.2 # degrees +CRADLE_LATITUDE = -35.0 # degrees (Southern hemisphere) +WAIST_RATIO = 0.7 +TWIST_DEG = 60.0 +BEAM_OUTER = 10.0 # mm +BEAM_WALL = 2.0 # mm +MARS_OBLATENESS = 0.00648 + +# Ring parameters +BASE_RING_RADIUS = BASE_DIAMETER / 2.0 # Same as arc base radius +CRADLE_RING_RADIUS = None # Computed based on cradle latitude and globe size + + +# ============================================================================ +# Profile Generation +# ============================================================================ + +def box_beam_profile(outer_size: float, wall_thickness: float) -> list: + """Create outer profile for hollow box beam (region2d format). + + Returns a region2d as list containing one polyline (the outer boundary). + """ + half = outer_size / 2.0 + outline = [ + line(point(-half, -half, 0), point(half, -half, 0)), + line(point(half, -half, 0), point(half, half, 0)), + line(point(half, half, 0), point(-half, half, 0)), + line(point(-half, half, 0), point(-half, -half, 0)), + ] + return [outline] + + +def box_beam_inner(outer_size: float, wall_thickness: float) -> list: + """Create inner void profile for hollow box beam (region2d format).""" + inner_half = outer_size / 2.0 - wall_thickness + outline = [ + line(point(-inner_half, -inner_half, 0), point(inner_half, -inner_half, 0)), + line(point(inner_half, -inner_half, 0), point(inner_half, inner_half, 0)), + line(point(inner_half, inner_half, 0), point(-inner_half, inner_half, 0)), + line(point(-inner_half, inner_half, 0), point(-inner_half, -inner_half, 0)), + ] + return [outline] + + +# ============================================================================ +# Support Arc Path Generation +# ============================================================================ + +def deg2rad(degrees: float) -> float: + return degrees * math.pi / 180.0 + + +def support_arc_point( + base_x: float, base_y: float, base_z: float, + top_x: float, top_y: float, top_z: float, + waist_ratio: float, + a_clear: float, c_clear: float, + cos_tilt: float, sin_tilt: float, + globe_center_z: float, + sigmoid_k: float, + t: float +) -> tuple: + """Compute a single point on the support arc with sigmoid blending. + + Returns (x, y, z) tuple. + """ + # Parabolic factor peaks at t=0.5 + para = 4.0 * t * (1.0 - t) + + # Linear interpolation + lin_x = base_x + (top_x - base_x) * t + lin_y = base_y + (top_y - base_y) * t + lin_z = base_z + (top_z - base_z) * t + + # Apply waist (radial contraction) + lin_r = math.sqrt(lin_x * lin_x + lin_y * lin_y) + if lin_r < 1e-10: + lin_r = 1e-10 + waist_offset = (1.0 - waist_ratio) * lin_r * para + scale = (lin_r - waist_offset) / lin_r + raw_x = lin_x * scale + raw_y = lin_y * scale + raw_z = lin_z + + # Transform to local (untilted) frame to check against clearance ellipsoid + loc_x = raw_x + loc_y = raw_y * cos_tilt + (raw_z - globe_center_z) * sin_tilt + loc_z = -raw_y * sin_tilt + (raw_z - globe_center_z) * cos_tilt + + # Check if inside clearance ellipsoid + a_clear2 = a_clear * a_clear + c_clear2 = c_clear * c_clear + check = (loc_x*loc_x + loc_y*loc_y) / a_clear2 + loc_z*loc_z / c_clear2 + + if check < 1e-10: + check = 1e-10 + + # Sigmoid blend factor: 1 when inside (check<1), 0 when outside + d = check - 1.0 + s = 1.0 / (1.0 + math.exp(sigmoid_k * d)) + + # Project to clearance surface + proj_t = 1.0 / math.sqrt(check) + proj_loc_x = loc_x * proj_t + proj_loc_y = loc_y * proj_t + proj_loc_z = loc_z * proj_t + + # Transform projected point back to world frame + proj_world_x = proj_loc_x + proj_world_y = proj_loc_y * cos_tilt - proj_loc_z * sin_tilt + proj_world_z = proj_loc_y * sin_tilt + proj_loc_z * cos_tilt + globe_center_z + + # Blend between raw and projected based on sigmoid + x = (1.0 - s) * raw_x + s * proj_world_x + y = (1.0 - s) * raw_y + s * proj_world_y + z = (1.0 - s) * raw_z + s * proj_world_z + + return (x, y, z) + + +def wrapped_support_arc_path( + base_radius: float, + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + globe_center_z: float, + beam_offset: float, + waist_ratio: float, + base_angle_deg: float, + top_longitude_deg: float, + num_segments: int = 17 +) -> dict: + """Generate the 3D path for a support arc. + + Returns path3d dict compatible with manufacturing module. + """ + globe_radius = globe_diameter / 2.0 + polar_radius = globe_radius * (1.0 - oblateness) + + # Clearance ellipsoid = globe + beam_offset + a_clear = globe_radius + beam_offset + c_clear = polar_radius + beam_offset + + sigmoid_k = 20.0 + + lat_rad = deg2rad(latitude_deg) + tilt_rad = deg2rad(tilt_deg) + base_angle_rad = deg2rad(base_angle_deg) + top_lon_rad = deg2rad(top_longitude_deg) + + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_tilt = math.cos(tilt_rad) + sin_tilt = math.sin(tilt_rad) + + a2 = globe_radius * globe_radius + c2 = polar_radius * polar_radius + + # Base point (on flat ring at z=0) + base_x = base_radius * math.cos(base_angle_rad) + base_y = base_radius * math.sin(base_angle_rad) + base_z = 0.0 + + # Top point: on latitude circle with beam offset + tx = globe_radius * cos_lat * math.cos(top_lon_rad) + ty = globe_radius * cos_lat * math.sin(top_lon_rad) + tz = polar_radius * sin_lat + tnx = tx / a2 + tny = ty / a2 + tnz = tz / c2 + tnlen = math.sqrt(tnx*tnx + tny*tny + tnz*tnz) + if tnlen < 1e-10: + tnlen = 1e-10 + tpx = tx + beam_offset * tnx / tnlen + tpy = ty + beam_offset * tny / tnlen + tpz = tz + beam_offset * tnz / tnlen + top_x = tpx + top_y = tpy * cos_tilt - tpz * sin_tilt + top_z = tpy * sin_tilt + tpz * cos_tilt + globe_center_z + + # Generate intermediate points + # t values: 1/(num_segments), 2/(num_segments), ..., (num_segments-1)/(num_segments) + arc_points = [] + for i in range(1, num_segments): + t = i / num_segments + pt = support_arc_point( + base_x, base_y, base_z, top_x, top_y, top_z, + waist_ratio, a_clear, c_clear, + cos_tilt, sin_tilt, globe_center_z, sigmoid_k, t + ) + arc_points.append(pt) + + # Build path segments + segments = [] + + # First segment: base -> first intermediate + segments.append({ + 'type': 'line', + 'start': [base_x, base_y, base_z], + 'end': list(arc_points[0]) + }) + + # Middle segments + for i in range(len(arc_points) - 1): + segments.append({ + 'type': 'line', + 'start': list(arc_points[i]), + 'end': list(arc_points[i + 1]) + }) + + # Last segment: last intermediate -> top + segments.append({ + 'type': 'line', + 'start': list(arc_points[-1]), + 'end': [top_x, top_y, top_z] + }) + + return {'segments': segments} + + +def compute_globe_center_z( + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + beam_offset: float, + target_cradle_low_z: float = 300.0 +) -> float: + """Calculate globe center Z so lowest cradle point is at target height.""" + globe_radius = globe_diameter / 2.0 + polar_radius = globe_radius * (1.0 - oblateness) + + lat_rad = deg2rad(latitude_deg) + tilt_rad = deg2rad(tilt_deg) + + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_tilt = math.cos(tilt_rad) + sin_tilt = math.sin(tilt_rad) + + a2 = globe_radius * globe_radius + c2 = polar_radius * polar_radius + + # Point at 270° longitude (negative Y) is the lowest + x270 = 0.0 + y270 = -globe_radius * cos_lat + z270 = polar_radius * sin_lat + nx270 = x270 / a2 + ny270 = y270 / a2 + nz270 = z270 / c2 + nlen270 = math.sqrt(nx270*nx270 + ny270*ny270 + nz270*nz270) + if nlen270 < 1e-10: + nlen270 = 1e-10 + px270 = x270 + beam_offset * nx270 / nlen270 + py270 = y270 + beam_offset * ny270 / nlen270 + pz270 = z270 + beam_offset * nz270 / nlen270 + rz270 = py270 * sin_tilt + pz270 * cos_tilt + + return target_cradle_low_z - rz270 + + +def cradle_point( + globe_radius: float, + polar_radius: float, + cos_lat: float, + sin_lat: float, + cos_tilt: float, + sin_tilt: float, + beam_offset: float, + globe_center_z: float, + longitude_deg: float, +) -> tuple: + """Compute a single point on the latitude circle (matching DSL cradle_point). + + Returns world-space (x, y, z) after ellipsoid offset and tilt rotation. + """ + lon_rad = deg2rad(longitude_deg) + cos_lon = math.cos(lon_rad) + sin_lon = math.sin(lon_rad) + + # Point on latitude circle (before tilt) + x = globe_radius * cos_lat * cos_lon + y = globe_radius * cos_lat * sin_lon + z = polar_radius * sin_lat + + # Normal vector for offset (gradient of ellipsoid) + a2 = globe_radius * globe_radius + c2 = polar_radius * polar_radius + nx = x / a2 + ny = y / a2 + nz = z / c2 + nlen = math.sqrt(nx*nx + ny*ny + nz*nz) + if nlen < 1e-10: + nlen = 1e-10 + + # Offset point along normal + px = x + beam_offset * nx / nlen + py = y + beam_offset * ny / nlen + pz = z + beam_offset * nz / nlen + + # Apply tilt rotation around X axis, then translate + rx = px + ry = py * cos_tilt - pz * sin_tilt + rz = py * sin_tilt + pz * cos_tilt + globe_center_z + + return (rx, ry, rz) + + +def latitude_cradle_path( + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + globe_center_z: float, + beam_offset: float, + num_segments: int = 32, +) -> dict: + """Generate the 3D path for a latitude cradle ring (matching DSL). + + Returns path3d dict compatible with manufacturing module. + """ + globe_radius = globe_diameter / 2.0 + polar_radius = globe_radius * (1.0 - oblateness) + + lat_rad = deg2rad(latitude_deg) + tilt_rad = deg2rad(tilt_deg) + + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_tilt = math.cos(tilt_rad) + sin_tilt = math.sin(tilt_rad) + + # Generate points around the latitude circle + angle_step = 360.0 / num_segments + points = [] + for i in range(num_segments): + longitude = i * angle_step + pt = cradle_point( + globe_radius, polar_radius, cos_lat, sin_lat, + cos_tilt, sin_tilt, beam_offset, globe_center_z, + longitude + ) + points.append(pt) + + # Build path segments connecting consecutive points (closing the loop) + segments = [] + for i in range(num_segments): + next_i = (i + 1) % num_segments + segments.append({ + 'type': 'line', + 'start': list(points[i]), + 'end': list(points[next_i]) + }) + + return {'segments': segments} + + +def create_latitude_cradle_ring( + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + globe_center_z: float, + beam_outer: float, + beam_wall: float, + ring_id: str = "cradle_ring", + num_path_segments: int = 32, +) -> tuple: + """Create a cradle ring solid following the latitude circle on tilted oblate spheroid. + + Returns: + Tuple of (solid, SweptElementProvenance) + """ + from yapcad.geom3d_util import sweep_adaptive + from yapcad.manufacturing import SweptElementProvenance, path_length + + beam_offset = beam_outer / 2.0 + + # Generate the latitude path + spine = latitude_cradle_path( + globe_diameter, oblateness, latitude_deg, tilt_deg, + globe_center_z, beam_offset, num_path_segments + ) + + # Create hollow box profile + outer_profile = box_beam_profile(beam_outer, beam_wall) + inner_profile = box_beam_inner(beam_outer, beam_wall) + + # Sweep the profile along the path + ring_solid = sweep_adaptive( + outer_profile[0], + spine, + angle_threshold_deg=5.0, + inner_profiles=[inner_profile[0]], + ) + + # Create provenance + provenance = SweptElementProvenance( + id=ring_id, + operation="sweep_adaptive", + outer_profile=outer_profile, + spine=spine, + wall_thickness=beam_wall, + metadata={'solid': ring_solid} + ) + + return ring_solid, provenance + + +def compute_cradle_ring_params( + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + beam_offset: float, + globe_center_z: float, +) -> tuple: + """Compute cradle ring approximate radius and center Z position. + + NOTE: This returns approximate values for display purposes only. + The actual cradle ring is a 3D path, not a simple tilted circle. + + Returns: + Tuple of (approximate_radius, approximate_center_z) + """ + globe_radius = globe_diameter / 2.0 + polar_radius = globe_radius * (1.0 - oblateness) + + lat_rad = deg2rad(latitude_deg) + tilt_rad = deg2rad(tilt_deg) + + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_tilt = math.cos(tilt_rad) + sin_tilt = math.sin(tilt_rad) + + # Compute a representative point on the latitude circle (at 0° longitude) + pt0 = cradle_point( + globe_radius, polar_radius, cos_lat, sin_lat, + cos_tilt, sin_tilt, beam_offset, globe_center_z, + 0.0 + ) + + # Approximate radius from the XY plane distance + approx_radius = math.sqrt(pt0[0]**2 + pt0[1]**2) + + # Use average Z from 0° and 180° longitude points + pt180 = cradle_point( + globe_radius, polar_radius, cos_lat, sin_lat, + cos_tilt, sin_tilt, beam_offset, globe_center_z, + 180.0 + ) + approx_z = (pt0[2] + pt180[2]) / 2.0 + + return approx_radius, approx_z + + +def compute_arc_endpoint_for_ring( + base_radius: float, + base_angle_deg: float, + top_longitude_deg: float, + globe_diameter: float, + oblateness: float, + latitude_deg: float, + tilt_deg: float, + beam_offset: float, + globe_center_z: float, + end: str, # "start" or "end" +) -> tuple: + """Compute the position and direction for arc terminal connector. + + Args: + end: "start" for base ring attachment, "end" for cradle ring + + Returns: + Tuple of (position, inward_direction) + """ + globe_radius = globe_diameter / 2.0 + polar_radius = globe_radius * (1.0 - oblateness) + + lat_rad = deg2rad(latitude_deg) + tilt_rad = deg2rad(tilt_deg) + base_angle_rad = deg2rad(base_angle_deg) + top_lon_rad = deg2rad(top_longitude_deg) + + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_tilt = math.cos(tilt_rad) + sin_tilt = math.sin(tilt_rad) + + if end == "start": + # Base attachment point - on flat ring at z=0 + x = base_radius * math.cos(base_angle_rad) + y = base_radius * math.sin(base_angle_rad) + z = 0.0 + # Direction: pointing into the ring (toward center) + dx = -math.cos(base_angle_rad) + dy = -math.sin(base_angle_rad) + dz = 0.0 + else: + # Cradle attachment point - on tilted latitude circle + a2 = globe_radius * globe_radius + c2 = polar_radius * polar_radius + + tx = globe_radius * cos_lat * math.cos(top_lon_rad) + ty = globe_radius * cos_lat * math.sin(top_lon_rad) + tz = polar_radius * sin_lat + + # Normal at this point + tnx = tx / a2 + tny = ty / a2 + tnz = tz / c2 + tnlen = math.sqrt(tnx*tnx + tny*tny + tnz*tnz) + if tnlen < 1e-10: + tnlen = 1e-10 + + # Offset by beam_offset along normal + x = tx + beam_offset * tnx / tnlen + y = ty + beam_offset * tny / tnlen + z = tz + beam_offset * tnz / tnlen + + # Transform to world coords (apply tilt) + world_x = x + world_y = y * cos_tilt - z * sin_tilt + world_z = y * sin_tilt + z * cos_tilt + globe_center_z + + # Direction: tangent to cradle circle, pointing inward + # Simplified: radial inward in the tilted cradle plane + dx = -math.cos(top_lon_rad) + dy = -math.sin(top_lon_rad) + dz = 0.0 + # Transform direction + world_dx = dx + world_dy = dy * cos_tilt + world_dz = dy * sin_tilt + + return (world_x, world_y, world_z), (world_dx, world_dy, world_dz) + + return (x, y, z), (dx, dy, dz) + + +# ============================================================================ +# Main Segmentation Logic +# ============================================================================ + +def create_support_arc_provenance( + arc_index: int, + base_angle_deg: float, + top_longitude_deg: float, + globe_center_z: float +) -> SweptElementProvenance: + """Create a support arc with full provenance tracking.""" + base_radius = BASE_DIAMETER / 2.0 + beam_offset = BEAM_OUTER / 2.0 + + # Create profiles + outer_profile = box_beam_profile(BEAM_OUTER, BEAM_WALL) + inner_profile = box_beam_inner(BEAM_OUTER, BEAM_WALL) + + # Create path + spine = wrapped_support_arc_path( + base_radius, GLOBE_DIAMETER, MARS_OBLATENESS, CRADLE_LATITUDE, + TILT_ANGLE, globe_center_z, beam_offset, + WAIST_RATIO, base_angle_deg, top_longitude_deg + ) + + # Build swept solid + print(f" Building support arc {arc_index} solid...") + try: + arc_solid = sweep_adaptive( + outer_profile[0], # outer boundary polyline + spine, + inner_profiles=[inner_profile[0]], # inner void polyline + angle_threshold_deg=5.0 + ) + except Exception as e: + print(f" Warning: Could not build solid for arc {arc_index}: {e}") + arc_solid = None + + # Create provenance + return SweptElementProvenance( + id=f"support_arc_{arc_index}", + operation="sweep_adaptive_hollow", + outer_profile=outer_profile, + spine=spine, + inner_profile=inner_profile, + wall_thickness=BEAM_WALL, + metadata={'solid': arc_solid} + ) + + +def segment_globe_stand(max_segment_length: float = 200.0, output_dir: str = "output", + export_step: bool = True, create_package: bool = False, + include_rings: bool = False): + """Segment the globe stand support arcs and generate outputs. + + Args: + max_segment_length: Maximum length of each segment in mm (default: 200) + output_dir: Output directory name (default: "output") + export_step: Whether to export STEP files (default: True) + create_package: Whether to create a yapCAD package for visualization (default: False) + include_rings: Whether to generate base and cradle rings (default: False) + """ + + print("=" * 60) + print("Globe Stand Segmentation Example") + print("=" * 60) + print() + print(f"Parameters:") + print(f" Globe diameter: {GLOBE_DIAMETER} mm") + print(f" Base diameter: {BASE_DIAMETER} mm") + print(f" Beam profile: {BEAM_OUTER}x{BEAM_OUTER} mm, wall: {BEAM_WALL} mm") + print(f" Max segment length: {max_segment_length} mm") + print(f" STEP export: {export_step}") + print(f" Create package: {create_package}") + print(f" Include rings: {include_rings}") + print() + + # Calculate globe center Z + beam_offset = BEAM_OUTER / 2.0 + globe_center_z = compute_globe_center_z( + GLOBE_DIAMETER, MARS_OBLATENESS, CRADLE_LATITUDE, + TILT_ANGLE, beam_offset + ) + print(f" Globe center Z: {globe_center_z:.1f} mm") + print() + + # Create output directory + output_path = Path(__file__).parent / output_dir + output_path.mkdir(exist_ok=True) + + # Define the three support arcs (at 0°, 120°, 240°) + arc_configs = [ + (0, 0.0, TWIST_DEG), + (1, 120.0, 120.0 + TWIST_DEG), + (2, 240.0, 240.0 + TWIST_DEG), + ] + + all_results = [] + all_solids = [] # Collect (id, solid, type) tuples for package creation + terminal_segments = {} # Track terminal segments for ring integration: {arc_id: (first_seg, last_seg, prov)} + arc_solids = {} # Store full arc solids before segmentation: {arc_idx: solid} + assembly_manifest = { + 'project': 'Mars Globe Stand', + 'max_segment_length': max_segment_length, + 'beam_profile': f'{BEAM_OUTER}x{BEAM_OUTER}mm hollow, {BEAM_WALL}mm wall', + 'fit_clearance': FIT_CLEARANCE['press'], + 'parts': [] + } + + print("Building and segmenting support arcs...") + print("-" * 40) + + for arc_idx, base_angle, top_lon in arc_configs: + print(f"\nArc {arc_idx}: base={base_angle}°, top={top_lon}°") + + # Create provenance + prov = create_support_arc_provenance(arc_idx, base_angle, top_lon, globe_center_z) + + # Calculate path length + arc_length = path_length(prov.spine) + print(f" Path length: {arc_length:.1f} mm") + + # Compute optimal cuts + cuts = compute_optimal_cuts(prov, max_segment_length) + num_cuts = len(cuts) + print(f" Cuts needed: {num_cuts}") + + if cuts: + for i, cut in enumerate(cuts): + print(f" Cut {i}: t={cut.parameter:.3f}") + + # Skip segmentation if no solid was built + if prov.metadata.get('solid') is None: + print(f" Skipping segmentation (no solid)") + assembly_manifest['parts'].append({ + 'id': prov.id, + 'type': 'support_arc', + 'status': 'not_segmented', + 'reason': 'solid_build_failed', + 'path_length_mm': arc_length + }) + continue + + # Store full arc solid for cutting ring holes (before segmentation) + arc_solids[arc_idx] = prov.metadata['solid'] + + # Perform segmentation + if cuts: + try: + result = segment_swept_element(prov, cuts) + print(f" Created {result.segment_count} segments") + + # Record and export segments + for seg in result.segments: + step_file = None + if export_step and seg.solid is not None: + step_filename = f"{seg.id}.step" + step_filepath = str(output_path / step_filename) + if export_solid_to_step(seg.solid, step_filepath): + step_file = step_filename + print(f" Exported: {step_filename}") + + # Collect solid for package creation + if seg.solid is not None: + all_solids.append((seg.id, seg.solid, 'segment')) + + part_info = { + 'id': seg.id, + 'type': 'arc_segment', + 'parent': prov.id, + 'parameter_range': list(seg.parameter_range), + 'mates_with': seg.mates_with, + 'has_connector_tab': seg.has_connector_tab, + 'connector_type': seg.connector_type, + 'step_file': step_file + } + assembly_manifest['parts'].append(part_info) + + # Build and export connectors + # Build connector solids if we need them for STEP export or package + if (export_step or create_package) and result.connectors: + print(f" Building connector solids...") + try: + connector_solids = build_connector_solids(prov, result.connectors) + for conn_spec, conn_solid in connector_solids: + # Collect solid for package creation + all_solids.append((conn_spec.id, conn_solid, 'connector')) + + step_filename = None + if export_step: + step_filename = f"{conn_spec.id}.step" + step_filepath = str(output_path / step_filename) + if export_solid_to_step(conn_solid, step_filepath): + print(f" Exported: {step_filename}") + else: + step_filename = None + + conn_info = { + 'id': conn_spec.id, + 'type': 'interior_connector', + 'parent': prov.id, + 'center_parameter': conn_spec.center_parameter, + 'length_mm': conn_spec.length, + 'fit_clearance_mm': conn_spec.fit_clearance, + 'step_file': step_filename + } + assembly_manifest['parts'].append(conn_info) + except Exception as e: + print(f" Warning: Connector build failed: {e}") + # Still record connector specs without solid export + for conn in result.connectors: + conn_info = { + 'id': conn.id, + 'type': 'interior_connector', + 'parent': prov.id, + 'center_parameter': conn.center_parameter, + 'length_mm': conn.length, + 'fit_clearance_mm': conn.fit_clearance, + 'step_file': None + } + assembly_manifest['parts'].append(conn_info) + elif result.connectors: + # Record connectors without building solids + for conn in result.connectors: + conn_info = { + 'id': conn.id, + 'type': 'interior_connector', + 'parent': prov.id, + 'center_parameter': conn.center_parameter, + 'length_mm': conn.length, + 'fit_clearance_mm': conn.fit_clearance, + 'step_file': None + } + assembly_manifest['parts'].append(conn_info) + + all_results.append((prov, result)) + + # Track terminal segments for ring integration + if result.segments: + first_seg = result.segments[0] + last_seg = result.segments[-1] + terminal_segments[prov.id] = (first_seg, last_seg, prov) + + # Print assembly instructions + if result.assembly_instructions: + print(f"\n Assembly instructions for arc {arc_idx}:") + for line_text in result.assembly_instructions.split('\n')[:10]: + print(f" {line_text}") + + except Exception as e: + print(f" Segmentation failed: {e}") + assembly_manifest['parts'].append({ + 'id': prov.id, + 'type': 'support_arc', + 'status': 'segmentation_failed', + 'error': str(e) + }) + else: + # No cuts needed, record as single piece + assembly_manifest['parts'].append({ + 'id': prov.id, + 'type': 'support_arc', + 'status': 'no_segmentation_needed', + 'path_length_mm': arc_length + }) + + # ======================================================================== + # Ring Generation (if --rings flag is set) + # ======================================================================== + if include_rings: + print("\n" + "-" * 40) + print("Generating rings with female holes...") + print("-" * 40) + + # Compute cradle ring parameters + cradle_radius, cradle_z = compute_cradle_ring_params( + GLOBE_DIAMETER, MARS_OBLATENESS, CRADLE_LATITUDE, + TILT_ANGLE, beam_offset, globe_center_z + ) + print(f" Cradle ring: radius={cradle_radius:.1f}mm, z={cradle_z:.1f}mm") + + # Base ring attachment angles (same as arc base angles) + base_attachment_angles = [0.0, 120.0, 240.0] + # Cradle ring attachment angles (twisted by TWIST_DEG) + cradle_attachment_angles = [TWIST_DEG, 120.0 + TWIST_DEG, 240.0 + TWIST_DEG] + + # Create base ring + print("\n Creating base ring...") + base_ring_solid, base_ring_prov = create_ring_solid( + radius=BASE_RING_RADIUS, + outer_width=BEAM_OUTER, + outer_height=BEAM_OUTER, + wall_thickness=BEAM_WALL, + center=(0.0, 0.0, 0.0), + tilt_angle_deg=0.0, + tilt_axis="x", + ring_id="base_ring" + ) + + # Cut female holes using arc solids (properly aligned to arc direction) + print(" Cutting holes using arc geometry...") + base_ring_with_holes = base_ring_solid + for arc_idx, arc_solid in arc_solids.items(): + try: + base_ring_with_holes = solid_boolean( + base_ring_with_holes, arc_solid, 'difference' + ) + print(f" Cut hole for arc {arc_idx} in base ring") + except Exception as e: + print(f" Warning: Failed to cut hole for arc {arc_idx}: {e}") + base_ring_prov.metadata['solid'] = base_ring_with_holes + + # Create cradle ring following the actual latitude path on tilted oblate spheroid + print("\n Creating cradle ring (latitude path)...") + cradle_ring_solid, cradle_ring_prov = create_latitude_cradle_ring( + globe_diameter=GLOBE_DIAMETER, + oblateness=MARS_OBLATENESS, + latitude_deg=CRADLE_LATITUDE, + tilt_deg=TILT_ANGLE, + globe_center_z=globe_center_z, + beam_outer=BEAM_OUTER, + beam_wall=BEAM_WALL, + ring_id="cradle_ring" + ) + print(f" Cradle ring: approx radius={cradle_radius:.1f}mm, approx z={cradle_z:.1f}mm") + + # Cut female holes using arc solids (properly aligned to arc direction) + print(" Cutting holes using arc geometry...") + cradle_ring_with_holes = cradle_ring_solid + for arc_idx, arc_solid in arc_solids.items(): + try: + cradle_ring_with_holes = solid_boolean( + cradle_ring_with_holes, arc_solid, 'difference' + ) + print(f" Cut hole for arc {arc_idx} in cradle ring") + except Exception as e: + print(f" Warning: Failed to cut hole for arc {arc_idx}: {e}") + cradle_ring_prov.metadata['solid'] = cradle_ring_with_holes + + # ==================================================================== + # Arc-Ring Integration: Trim terminal segments and add terminal tabs + # ==================================================================== + if terminal_segments: + print("\n Integrating arc segments with rings...") + + for arc_id, (first_seg, last_seg, prov) in terminal_segments.items(): + print(f" Processing {arc_id}...") + + # Find and update first segment (base ring connection) in all_solids + if first_seg.solid is not None: + # Trim against base ring + try: + trimmed_first = trim_segment_against_ring( + first_seg.solid, base_ring_solid + ) + # Update segment solid reference + first_seg.solid = trimmed_first + + # Update in all_solids list + for i, (sid, solid, stype) in enumerate(all_solids): + if sid == first_seg.id: + all_solids[i] = (sid, trimmed_first, stype) + break + + print(f" Trimmed {first_seg.id} against base ring") + except Exception as e: + print(f" Warning: Failed to trim {first_seg.id}: {e}") + + # Find and update last segment (cradle ring connection) in all_solids + if last_seg.solid is not None and last_seg.id != first_seg.id: + # Trim against cradle ring + try: + trimmed_last = trim_segment_against_ring( + last_seg.solid, cradle_ring_solid + ) + # Update segment solid reference + last_seg.solid = trimmed_last + + # Update in all_solids list + for i, (sid, solid, stype) in enumerate(all_solids): + if sid == last_seg.id: + all_solids[i] = (sid, trimmed_last, stype) + break + + print(f" Trimmed {last_seg.id} against cradle ring") + except Exception as e: + print(f" Warning: Failed to trim {last_seg.id}: {e}") + + # Now add terminal tabs to trimmed segments + # First segment gets a start tab (connects to base ring) + if first_seg.solid is not None: + try: + seg_with_tab = add_terminal_connectors_to_segment( + first_seg.solid, prov, + add_start=True, add_end=False, + fit_clearance=FIT_CLEARANCE['press'] + ) + first_seg.solid = seg_with_tab + + # Update in all_solids list + for i, (sid, solid, stype) in enumerate(all_solids): + if sid == first_seg.id: + all_solids[i] = (sid, seg_with_tab, stype) + break + + print(f" Added terminal tab to {first_seg.id} (start)") + except Exception as e: + print(f" Warning: Failed to add tab to {first_seg.id}: {e}") + + # Last segment gets an end tab (connects to cradle ring) + if last_seg.solid is not None and last_seg.id != first_seg.id: + try: + seg_with_tab = add_terminal_connectors_to_segment( + last_seg.solid, prov, + add_start=False, add_end=True, + fit_clearance=FIT_CLEARANCE['press'] + ) + last_seg.solid = seg_with_tab + + # Update in all_solids list + for i, (sid, solid, stype) in enumerate(all_solids): + if sid == last_seg.id: + all_solids[i] = (sid, seg_with_tab, stype) + break + + print(f" Added terminal tab to {last_seg.id} (end)") + except Exception as e: + print(f" Warning: Failed to add tab to {last_seg.id}: {e}") + + # Segment the rings + # Use 3 segments per ring to fit within Bambu Lab H2S build volume + # when laid flat (ring diameter ~400mm fits within 450x450mm build area) + RING_SEGMENTS = 3 + print("\n Segmenting base ring...") + base_circumference = 2 * math.pi * BASE_RING_RADIUS + base_cut_params = compute_ring_cuts_avoiding_holes( + base_circumference, max_segment_length, + base_attachment_angles, min_distance_from_hole=30.0, + target_segments=RING_SEGMENTS + ) + print(f" Circumference: {base_circumference:.1f}mm, cuts: {len(base_cut_params)}, target segments: {RING_SEGMENTS}") + + if base_cut_params: + # Use segment_closed_ring for proper closed ring segmentation + try: + base_ring_segments = segment_closed_ring( + base_ring_with_holes, + base_ring_prov.spine, + base_cut_params + ) + print(f" Created {len(base_ring_segments)} base ring segments") + + # Build segment ranges from cut params + all_cuts = [0.0] + sorted(base_cut_params) + [1.0] + for i, seg_solid in enumerate(base_ring_segments): + seg_id = f"base_ring_seg_{i}" + param_range = (all_cuts[i], all_cuts[i + 1]) + + if seg_solid is not None: + all_solids.append((seg_id, seg_solid, 'ring_segment')) + + if export_step: + step_filename = f"{seg_id}.step" + step_filepath = str(output_path / step_filename) + if export_solid_to_step(seg_solid, step_filepath): + print(f" Exported: {step_filename}") + + # Determine mating relationships + mates = [] + if i > 0: + mates.append(f"base_ring_seg_{i-1}") + if i < len(base_ring_segments) - 1: + mates.append(f"base_ring_seg_{i+1}") + + # Connector type: male for all but last + connector_type = "male" if i < len(base_ring_segments) - 1 else "female" + + assembly_manifest['parts'].append({ + 'id': seg_id, + 'type': 'ring_segment', + 'parent': 'base_ring', + 'parameter_range': list(param_range), + 'mates_with': mates, + 'connector_type': connector_type, + }) + except Exception as e: + print(f" Base ring segmentation failed: {e}") + import traceback + traceback.print_exc() + # Fall back to unsegmented ring + all_solids.append(('base_ring', base_ring_with_holes, 'ring')) + assembly_manifest['parts'].append({ + 'id': 'base_ring', + 'type': 'ring', + 'segmented': False, + }) + else: + # No segmentation needed + all_solids.append(('base_ring', base_ring_with_holes, 'ring')) + assembly_manifest['parts'].append({ + 'id': 'base_ring', + 'type': 'ring', + 'segmented': False, + }) + + print("\n Segmenting cradle ring...") + cradle_circumference = 2 * math.pi * cradle_radius + cradle_cut_params = compute_ring_cuts_avoiding_holes( + cradle_circumference, max_segment_length, + cradle_attachment_angles, min_distance_from_hole=30.0, + target_segments=RING_SEGMENTS + ) + print(f" Circumference: {cradle_circumference:.1f}mm, cuts: {len(cradle_cut_params)}, target segments: {RING_SEGMENTS}") + + if cradle_cut_params: + # Use segment_closed_ring for proper closed ring segmentation + try: + cradle_ring_segments = segment_closed_ring( + cradle_ring_with_holes, + cradle_ring_prov.spine, + cradle_cut_params + ) + print(f" Created {len(cradle_ring_segments)} cradle ring segments") + + # Build segment ranges from cut params + all_cuts = [0.0] + sorted(cradle_cut_params) + [1.0] + for i, seg_solid in enumerate(cradle_ring_segments): + seg_id = f"cradle_ring_seg_{i}" + param_range = (all_cuts[i], all_cuts[i + 1]) + + if seg_solid is not None: + all_solids.append((seg_id, seg_solid, 'ring_segment')) + + if export_step: + step_filename = f"{seg_id}.step" + step_filepath = str(output_path / step_filename) + if export_solid_to_step(seg_solid, step_filepath): + print(f" Exported: {step_filename}") + + # Determine mating relationships + mates = [] + if i > 0: + mates.append(f"cradle_ring_seg_{i-1}") + if i < len(cradle_ring_segments) - 1: + mates.append(f"cradle_ring_seg_{i+1}") + + # Connector type: male for all but last + connector_type = "male" if i < len(cradle_ring_segments) - 1 else "female" + + assembly_manifest['parts'].append({ + 'id': seg_id, + 'type': 'ring_segment', + 'parent': 'cradle_ring', + 'parameter_range': list(param_range), + 'mates_with': mates, + 'connector_type': connector_type, + }) + except Exception as e: + print(f" Cradle ring segmentation failed: {e}") + import traceback + traceback.print_exc() + all_solids.append(('cradle_ring', cradle_ring_with_holes, 'ring')) + assembly_manifest['parts'].append({ + 'id': 'cradle_ring', + 'type': 'ring', + 'segmented': False, + }) + else: + all_solids.append(('cradle_ring', cradle_ring_with_holes, 'ring')) + assembly_manifest['parts'].append({ + 'id': 'cradle_ring', + 'type': 'ring', + 'segmented': False, + }) + + # Write assembly manifest + manifest_path = output_path / "assembly_manifest.json" + with open(manifest_path, 'w') as f: + json.dump(assembly_manifest, f, indent=2) + print(f"\nWrote assembly manifest to: {manifest_path}") + + # Create assembled package if requested + package_created = False + if create_package: + package_created = create_assembled_package( + all_solids, output_path, max_segment_length + ) + + # Count exported files + step_files = [p.get('step_file') for p in assembly_manifest['parts'] if p.get('step_file')] + + # Summary + print() + print("=" * 60) + print("Summary") + print("=" * 60) + total_segments = sum(r.segment_count for _, r in all_results) + total_connectors = sum(len(r.connectors) for _, r in all_results) + print(f" Total segments: {total_segments}") + print(f" Total connectors: {total_connectors}") + print(f" STEP files exported: {len(step_files)}") + if package_created: + print(f" Assembly package: assembled.ycpkg") + print(f" Output directory: {output_path}") + print() + print("Output files:") + print(f" - assembly_manifest.json") + for f in step_files: + print(f" - {f}") + if package_created: + print(f" - assembled.ycpkg/") + + return all_results, assembly_manifest + + +# ============================================================================ +# Entry Point +# ============================================================================ + +def print_usage(): + """Print usage information.""" + print("Usage: python segment_globe_stand.py [OPTIONS] [max_segment_length]") + print() + print("Options:") + print(" --no-step Skip STEP file export") + print(" --package Create yapCAD package with assembled visualization") + print(" --rings Include base and cradle rings with female holes") + print(" --help, -h Show this help message") + print() + print("Arguments:") + print(" max_segment_length Maximum segment length in mm (default: 200)") + print() + print("Examples:") + print(" python segment_globe_stand.py # Default: 200mm segments, STEP export") + print(" python segment_globe_stand.py 150 # 150mm segments, STEP export") + print(" python segment_globe_stand.py --no-step # 200mm segments, no STEP export") + print(" python segment_globe_stand.py --package # Create assembled visualization package") + print(" python segment_globe_stand.py --rings # Include rings with female holes") + + +if __name__ == '__main__': + max_length = 200.0 + export_step = True + create_package = False + include_rings = False + + args = sys.argv[1:] + + # Parse arguments + for arg in args: + if arg in ('--help', '-h'): + print_usage() + sys.exit(0) + elif arg == '--no-step': + export_step = False + elif arg == '--package': + create_package = True + elif arg == '--rings': + include_rings = True + else: + try: + max_length = float(arg) + except ValueError: + print(f"Error: Invalid argument: {arg}") + print_usage() + sys.exit(1) + + segment_globe_stand(max_length, export_step=export_step, create_package=create_package, + include_rings=include_rings) diff --git a/src/yapcad/dsl/packaging.py b/src/yapcad/dsl/packaging.py index 9c6b5be..d14d210 100644 --- a/src/yapcad/dsl/packaging.py +++ b/src/yapcad/dsl/packaging.py @@ -7,7 +7,10 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from yapcad.package import PackageManifest from .runtime import compile_and_run, ExecutionResult diff --git a/src/yapcad/dsl/parser.py b/src/yapcad/dsl/parser.py index 1a6d60a..9496819 100644 --- a/src/yapcad/dsl/parser.py +++ b/src/yapcad/dsl/parser.py @@ -33,6 +33,8 @@ error_unexpected_eof, error_invalid_expression, DiagnosticCollector, + Diagnostic, + ErrorSeverity, ) @@ -738,11 +740,14 @@ def _parse_statement(self) -> Statement: # While loop - DEPRECATED, give helpful error if token.type == TokenType.WHILE: - raise ParseError( - "'while' loops are not supported (removed for static verifiability). " - "Use 'for i in range(max_iterations)' with early return instead.", - token.span + diag = Diagnostic( + code="E104", + message="'while' loops are not supported (removed for static verifiability). " + "Use 'for i in range(max_iterations)' with early return instead.", + severity=ErrorSeverity.ERROR, + span=token.span, ) + raise ParserError(diag) # If statement if token.type == TokenType.IF: diff --git a/src/yapcad/fasteners_legacy.py b/src/yapcad/fasteners_legacy.py new file mode 100644 index 0000000..e1f3788 --- /dev/null +++ b/src/yapcad/fasteners_legacy.py @@ -0,0 +1,549 @@ +"""Parametric fastener helpers (threads + hex-cap screws).""" + +from __future__ import annotations + +import math +from copy import deepcopy +from dataclasses import asdict, dataclass, replace +from typing import Dict, Iterable + +from yapcad.geom import arc, epsilon, point +from yapcad.geom_util import geomlist2poly +from yapcad.geom3d import ( + poly2surfaceXY, + reversesurface, + solid, + solid_boolean, + translate, +) +from yapcad.geom3d_util import ( + circleSurface, + conic, + extrude, + makeRevolutionThetaSamplingSurface, +) +from yapcad.metadata import add_tags, get_solid_metadata, set_layer +from yapcad.brep import has_brep_data, occ_available +from yapcad.threadgen import ThreadProfile, metric_profile, sample_thread_profile, unified_profile + +__all__ = [ + "HexCapScrewSpec", + "build_hex_cap_screw", + "metric_hex_cap_screw", + "unified_hex_cap_screw", + "metric_hex_cap_catalog", + "unified_hex_cap_catalog", + "HexNutSpec", + "build_hex_nut", + "metric_hex_nut", + "unified_hex_nut", + "metric_hex_nut_catalog", + "unified_hex_nut_catalog", +] + + +@dataclass(frozen=True) +class HexCapScrewSpec: + """Dimensions (all millimeters) for :func:`build_hex_cap_screw`.""" + + diameter: float + thread_length: float + shank_length: float + head_height: float + head_flat_diameter: float + washer_thickness: float = 0.5 + washer_diameter: float | None = None + shank_diameter: float | None = None + starts: int = 1 + thread_arc_samples: int = 180 + thread_samples_per_pitch: int = 6 + + +@dataclass(frozen=True) +class HexNutSpec: + diameter: float + pitch: float + width_flat: float + thickness: float + handedness: str = "right" + starts: int = 1 + thread_arc_samples: int = 180 + thread_samples_per_pitch: int = 6 + + +def build_hex_cap_screw(profile: ThreadProfile, spec: HexCapScrewSpec): + """Create a watertight solid representing a hex cap screw. + + Args: + profile: External thread profile describing nominal diameter/pitch. + spec: Geometry parameters (all millimeters). + """ + + _validate_spec(spec) + thread = _build_thread(profile, spec) + components = [thread] + unthreaded = max(spec.shank_length - spec.thread_length, 0.0) + if unthreaded > epsilon: + components.append(_build_shank(spec, unthreaded)) + + if spec.washer_thickness > epsilon: + components.append(_build_washer(spec)) + + components.append(_build_hex_head(spec)) + try: + brep_parts = [part for part in components if has_brep_data(part)] + non_brep_parts = [part for part in components if not has_brep_data(part)] + if brep_parts: + body = _union_solids(brep_parts) + else: + body = non_brep_parts.pop(0) + for part in non_brep_parts: + body = solid_boolean(body, part, "union") + except Exception as e: + print(f"got exception {e} performing unions, stacking instead") + body = _stack_solids(components) + _scrub_surface_octrees(body) + meta = get_solid_metadata(body, create=True) + add_tags(meta, ["fastener", "hex_cap_screw"]) + set_layer(meta, "hardware") + meta["hex_cap_screw"] = { + "diameter": spec.diameter, + "thread_length": spec.thread_length, + "shank_length": spec.shank_length, + "head_height": spec.head_height, + "head_flat": spec.head_flat_diameter, + "washer_thickness": spec.washer_thickness, + "washer_diameter": spec.washer_diameter or spec.head_flat_diameter, + } + return body + + +def build_hex_nut(profile: ThreadProfile, spec: HexNutSpec): + if spec.thickness <= epsilon: + raise ValueError("nut thickness must be positive") + if spec.width_flat <= epsilon: + raise ValueError("nut width across flats must be positive") + + nut_profile = replace( + profile, + internal=True, + handedness=spec.handedness, + starts=spec.starts, + ) + + thread_surface, hole_radius = _build_internal_thread_surface(nut_profile, spec) + side_surfaces = _build_hex_side_surfaces(spec.width_flat, spec.thickness) + top_surface, bottom_surface = _build_hex_cap_with_hole(spec.width_flat, hole_radius, spec.thickness) + + surfaces = [bottom_surface, *side_surfaces, thread_surface, top_surface] + nut = solid(surfaces) + _scrub_surface_octrees(nut) + + meta = get_solid_metadata(nut, create=True) + add_tags(meta, ["fastener", "hex_nut"]) + set_layer(meta, "hardware") + meta["hex_nut"] = { + "diameter": spec.diameter, + "pitch": spec.pitch, + "width_flat": spec.width_flat, + "thickness": spec.thickness, + "starts": spec.starts, + "handedness": spec.handedness, + } + return nut + + +def metric_hex_cap_screw( + size: str, + length: float, + *, + thread_length: float | None = None, + starts: int = 1, + thread_arc_samples: int = 180, + thread_samples_per_pitch: int = 6, +): + """Return a hex screw for a metric size (e.g. ``'M8'``).""" + + dims = _METRIC_TABLE[size.upper()] + tl = thread_length or _default_thread_length(length, dims.diameter) + tl = min(max(tl, epsilon), length) + profile = metric_profile(dims.diameter, dims.pitch) + if starts != profile.starts: + profile = replace(profile, starts=starts) + washer_diameter, washer_thickness = _normalized_washer_dims( + head_flat=dims.head_flat, + base_thickness=dims.washer_thickness, + ) + spec = HexCapScrewSpec( + diameter=dims.diameter, + thread_length=tl, + shank_length=length, + head_height=dims.head_height, + head_flat_diameter=dims.head_flat, + washer_thickness=washer_thickness, + washer_diameter=washer_diameter, + thread_arc_samples=thread_arc_samples, + thread_samples_per_pitch=thread_samples_per_pitch, + ) + return build_hex_cap_screw(profile, spec) + + +def unified_hex_cap_screw( + size: str, + length_in: float, + *, + thread_length_in: float | None = None, + starts: int = 1, + thread_arc_samples: int = 180, + thread_samples_per_pitch: int = 6, +): + """Return a hex screw for a UNC/UNF imperial size (e.g. ``'1/4-20'``).""" + + dims = _UNIFIED_TABLE[size.lower()] + length = length_in * 25.4 + tl = (thread_length_in * 25.4) if thread_length_in is not None else _default_thread_length(length, dims.diameter) + tl = min(max(tl, epsilon), length) + profile = unified_profile(dims.diameter / 25.4, dims.tpi) + if starts != profile.starts: + profile = replace(profile, starts=starts) + washer_diameter, washer_thickness = _normalized_washer_dims( + head_flat=dims.head_flat, + base_thickness=dims.washer_thickness, + ) + spec = HexCapScrewSpec( + diameter=dims.diameter, + thread_length=tl, + shank_length=length, + head_height=dims.head_height, + head_flat_diameter=dims.head_flat, + washer_thickness=washer_thickness, + washer_diameter=washer_diameter, + thread_arc_samples=thread_arc_samples, + thread_samples_per_pitch=thread_samples_per_pitch, + ) + return build_hex_cap_screw(profile, spec) + + +def metric_hex_nut( + size: str, + *, + starts: int = 1, + handedness: str = "right", + thread_arc_samples: int = 180, + thread_samples_per_pitch: int = 6, +): + dims = _METRIC_NUT_TABLE[size.upper()] + profile = metric_profile(dims.diameter, dims.pitch, internal=True) + spec = HexNutSpec( + diameter=dims.diameter, + pitch=dims.pitch, + width_flat=dims.width_flat, + thickness=dims.thickness, + starts=starts, + handedness=handedness, + thread_arc_samples=thread_arc_samples, + thread_samples_per_pitch=thread_samples_per_pitch, + ) + return build_hex_nut(profile, spec) + + +def unified_hex_nut( + size: str, + *, + starts: int = 1, + handedness: str = "right", + thread_arc_samples: int = 180, + thread_samples_per_pitch: int = 6, +): + dims = _UNIFIED_NUT_TABLE[size.lower()] + profile = unified_profile(dims.diameter / 25.4, dims.tpi, internal=True) + spec = HexNutSpec( + diameter=dims.diameter, + pitch=25.4 / dims.tpi, + width_flat=dims.width_flat, + thickness=dims.thickness, + starts=starts, + handedness=handedness, + thread_arc_samples=thread_arc_samples, + thread_samples_per_pitch=thread_samples_per_pitch, + ) + return build_hex_nut(profile, spec) + + +def _build_thread(profile: ThreadProfile, spec: HexCapScrewSpec): + samples0, _ = sample_thread_profile( + profile, + 0.0, + spec.thread_length, + 0.0, + samples_per_pitch=spec.thread_samples_per_pitch, + ) + bottom_r = samples0[0][1] + top_r = samples0[-1][1] + + def contour(_z0: float, _z1: float, theta: float): + return sample_thread_profile( + profile, + 0.0, + spec.thread_length, + theta, + samples_per_pitch=spec.thread_samples_per_pitch, + ) + + surface = makeRevolutionThetaSamplingSurface( + contour, + 0.0, + spec.thread_length, + arcSamples=max(24, spec.thread_arc_samples), + endcaps=False, + ) + bottom_cap = circleSurface(point(0, 0, 0), bottom_r, zup=False) + top_cap = circleSurface(point(0, 0, spec.thread_length), top_r, zup=True) + return solid([surface, bottom_cap, top_cap]) + + +def _build_shank(spec: HexCapScrewSpec, length: float): + radius = (spec.shank_diameter or spec.diameter) / 2.0 + return conic( + radius, + radius, + length, + center=point(0, 0, spec.thread_length), + ) + + +def _build_washer(spec: HexCapScrewSpec): + radius = (spec.washer_diameter or spec.head_flat_diameter) / 2.0 + return conic( + radius, + radius, + spec.washer_thickness, + center=point(0, 0, spec.shank_length), + ) + + +def _build_hex_head(spec: HexCapScrewSpec): + base_z = spec.shank_length + spec.washer_thickness + incircle = spec.head_flat_diameter / 2.0 + circum = incircle / math.cos(math.pi / 6.0) + pts = [] + for idx in range(6): + angle = math.pi / 6.0 + idx * (math.pi / 3.0) + pts.append(point(circum * math.cos(angle), circum * math.sin(angle), 0.0)) + pts.append(pts[0]) + surface, _ = poly2surfaceXY(pts) + head = extrude(surface, spec.head_height) + return translate(head, point(0, 0, base_z)) + + +def _circle_loop_xy(radius: float, minang: float = 5.0): + loop = geomlist2poly([arc(point(0, 0), radius)], minang=minang, minlen=0.0) + if not loop: + raise ValueError("failed to construct circle loop") + return [point(pt[0], pt[1], 0.0) for pt in loop] + + +def _build_hex_cap_with_hole(width_flat: float, hole_radius: float, thickness: float): + incircle = width_flat / 2.0 + circum = incircle / math.cos(math.pi / 6.0) + outer = [] + for idx in range(6): + angle = math.pi / 6.0 + idx * (math.pi / 3.0) + outer.append(point(circum * math.cos(angle), circum * math.sin(angle), 0.0)) + outer.append(outer[0]) + + hole_loop = list(_circle_loop_xy(hole_radius)) + + top_surface, _ = poly2surfaceXY(outer, holepolys=[hole_loop]) + top_surface = translate(top_surface, point(0, 0, thickness)) + + bottom_surface, _ = poly2surfaceXY(outer, holepolys=[hole_loop]) + bottom_surface = reversesurface(bottom_surface) + return top_surface, bottom_surface + + +def _build_internal_thread_surface(profile: ThreadProfile, spec: HexNutSpec): + samples, _ = sample_thread_profile( + profile, + 0.0, + spec.thickness, + 0.0, + samples_per_pitch=spec.thread_samples_per_pitch, + ) + min_radius = min(pt[1] for pt in samples) + max_radius = max(pt[1] for pt in samples) + + def contour(_z0: float, _z1: float, theta: float): + return sample_thread_profile( + profile, + 0.0, + spec.thickness, + theta, + samples_per_pitch=spec.thread_samples_per_pitch, + ) + + surface = makeRevolutionThetaSamplingSurface( + contour, + 0.0, + spec.thickness, + arcSamples=max(24, spec.thread_arc_samples), + endcaps=False, + ) + surface = reversesurface(surface) + return surface, max_radius + + +def _build_hex_side_surfaces(width_flat: float, thickness: float): + incircle = width_flat / 2.0 + circum = incircle / math.cos(math.pi / 6.0) + pts = [] + for idx in range(6): + angle = math.pi / 6.0 + idx * (math.pi / 3.0) + pts.append(point(circum * math.cos(angle), circum * math.sin(angle), 0.0)) + pts.append(pts[0]) + surface, _ = poly2surfaceXY(pts) + prism = extrude(surface, thickness) + # extrude returns [top, side strip, bottom]; keep side strip only + return [prism[1][1]] + + +def _default_thread_length(shank_length: float, diameter: float) -> float: + return max(shank_length - diameter * 0.75, shank_length * 0.65) + + +def _validate_spec(spec: HexCapScrewSpec): + if spec.diameter <= epsilon: + raise ValueError("diameter must be positive") + if spec.thread_length <= epsilon: + raise ValueError("thread_length must be positive") + if spec.thread_length - spec.shank_length > epsilon: + raise ValueError("thread_length must be <= shank_length") + if spec.head_height <= epsilon: + raise ValueError("head_height must be positive") + if spec.head_flat_diameter <= spec.diameter: + raise ValueError("head_flat_diameter must exceed the shank diameter") + if spec.thread_arc_samples < 12: + raise ValueError("thread_arc_samples must be >= 12") + if spec.thread_samples_per_pitch < 1: + raise ValueError("thread_samples_per_pitch must be >= 1") + + +def _union_solids(solids: Iterable[list]): + solids = list(solids) + if not solids: + raise ValueError("no solids supplied") + + def _run(engine=None): + result = solids[0] + for part in solids[1:]: + result = solid_boolean(result, part, "union", engine=engine) + return result + + use_occ = occ_available() and all(has_brep_data(s) for s in solids) + if use_occ: + try: + return _run(engine="occ") + except Exception: + pass + return _run(engine=None) + + +def _stack_solids(solids: Iterable[list]): + collected = [] + for solid_part in solids: + if not solid_part[1]: + continue + collected.extend([deepcopy(surf) for surf in solid_part[1]]) + if not collected: + raise ValueError("no surfaces found while stacking solids") + return solid(collected) + + +def _scrub_surface_octrees(sld): + for surf in sld[1]: + if len(surf) >= 7 and isinstance(surf[6], dict): + surf[6].pop('_octree', None) + surf[6].pop('_octree_dirty', None) + + +@dataclass(frozen=True) +class _MetricDims: + diameter: float + pitch: float + head_height: float + head_flat: float + washer_thickness: float + + +@dataclass(frozen=True) +class _UnifiedDims: + diameter: float # millimeters + tpi: float + head_height: float + head_flat: float + washer_thickness: float + + +_METRIC_TABLE: Dict[str, _MetricDims] = { + "M6": _MetricDims(5.884, 1.0, 4.0, 10.0, 0.4), + "M8": _MetricDims(7.866, 1.25, 5.3, 13.0, 0.5), + "M10": _MetricDims(9.85, 1.5, 6.4, 16.0, 0.6), +} + +_UNIFIED_TABLE: Dict[str, _UnifiedDims] = { + "1/4-20": _UnifiedDims(6.35, 20, 4.14, 11.11, 0.5), + "5/16-18": _UnifiedDims(7.9375, 18, 5.23, 12.7, 0.55), + "3/8-16": _UnifiedDims(9.525, 16, 6.22, 14.2875, 0.65), +} + + +@dataclass(frozen=True) +class _MetricNutDims: + diameter: float + pitch: float + width_flat: float + thickness: float + + +@dataclass(frozen=True) +class _UnifiedNutDims: + diameter: float + tpi: float + width_flat: float + thickness: float + + +_METRIC_NUT_TABLE: Dict[str, _MetricNutDims] = { + "M6": _MetricNutDims(6.147, 1.0, 10.0, 5.0), + "M8": _MetricNutDims(8.17, 1.25, 13.0, 6.5), + "M10": _MetricNutDims(10.1985, 1.5, 17.0, 8.0), +} + +_UNIFIED_NUT_TABLE: Dict[str, _UnifiedNutDims] = { + "1/4-20": _UnifiedNutDims(6.35, 20, 11.11, 5.0), + "5/16-18": _UnifiedNutDims(7.9375, 18, 13.5, 6.5), + "3/8-16": _UnifiedNutDims(9.525, 16, 16.0, 7.5), +} + + +def metric_hex_cap_catalog(): + return {name: asdict(dim) for name, dim in _METRIC_TABLE.items()} + + +def unified_hex_cap_catalog(): + return {name: asdict(dim) for name, dim in _UNIFIED_TABLE.items()} + + +def metric_hex_nut_catalog(): + return {name: asdict(dim) for name, dim in _METRIC_NUT_TABLE.items()} + + +def unified_hex_nut_catalog(): + return {name: asdict(dim) for name, dim in _UNIFIED_NUT_TABLE.items()} + + +def _normalized_washer_dims(head_flat: float, base_thickness: float | None): + diameter = 0.95 * head_flat + thickness = base_thickness if base_thickness is not None else head_flat * 0.05 + thickness = max(thickness / 2.0, epsilon * 10.0) + return diameter, thickness diff --git a/src/yapcad/manufacturing/__init__.py b/src/yapcad/manufacturing/__init__.py new file mode 100644 index 0000000..d61a2eb --- /dev/null +++ b/src/yapcad/manufacturing/__init__.py @@ -0,0 +1,137 @@ +"""Manufacturing post-processing for yapCAD. + +This module provides tools for preparing yapCAD designs for manufacturing, +including beam segmentation for build volume constraints and interior +connector generation. + +Phase 1 Implementation +---------------------- +- Beam segmentation for hollow swept elements +- Interior connectors with configurable fit tolerances +- Path3D evaluation utilities +- Assembly planning with mating relationships + +Example Usage +------------- +>>> from yapcad.manufacturing import ( +... CutPoint, segment_swept_element, SweptElementProvenance +... ) +>>> +>>> # Define provenance for a swept beam +>>> provenance = SweptElementProvenance( +... id="main_beam", +... operation="sweep_adaptive", +... outer_profile=outer_region2d, +... spine=path3d, +... wall_thickness=2.0, +... metadata={'solid': beam_solid} +... ) +>>> +>>> # Define cut points +>>> cuts = [ +... CutPoint("main_beam", parameter=0.33), +... CutPoint("main_beam", parameter=0.67), +... ] +>>> +>>> # Segment the beam +>>> result = segment_swept_element(provenance, cuts) +>>> print(f"Created {result.segment_count} segments") +""" + +# Data structures +from .data import ( + CutPoint, + Segment, + SegmentationResult, + ConnectorSpec, + SweptElementProvenance, +) + +# Path utilities +from .path_utils import ( + evaluate_path3d_at_t, + compute_cut_plane, + extract_sub_path, + path_length, + length_to_parameter, + parameter_to_length, +) + +# Connector generation +from .connectors import ( + FIT_CLEARANCE, + offset_rectangular_profile, + compute_inner_profile_dimensions, + compute_connector_profile_dimensions, + compute_connector_length, + create_connector_region2d, + create_interior_connector, + compute_connector_spec, + create_terminal_connector, + add_terminal_connectors_to_segment, +) + +# Segmentation operations +from .segmentation import ( + split_solid_at_plane, + segment_swept_element, + segment_closed_ring, + extract_segment_between_planes, + compute_optimal_cuts, + build_connector_solids, +) + +# Ring generation +from .rings import ( + create_ring_spine, + create_ring_profile, + create_ring_solid, + create_female_hole_solid, + compute_arc_attachment_point, + add_female_holes_to_ring, + trim_segment_against_ring, + compute_ring_cuts_avoiding_holes, +) + +__all__ = [ + # Data structures + "CutPoint", + "Segment", + "SegmentationResult", + "ConnectorSpec", + "SweptElementProvenance", + # Path utilities + "evaluate_path3d_at_t", + "compute_cut_plane", + "extract_sub_path", + "path_length", + "length_to_parameter", + "parameter_to_length", + # Connector generation + "FIT_CLEARANCE", + "offset_rectangular_profile", + "compute_inner_profile_dimensions", + "compute_connector_profile_dimensions", + "compute_connector_length", + "create_connector_region2d", + "create_interior_connector", + "compute_connector_spec", + "create_terminal_connector", + "add_terminal_connectors_to_segment", + # Segmentation operations + "split_solid_at_plane", + "segment_swept_element", + "segment_closed_ring", + "extract_segment_between_planes", + "compute_optimal_cuts", + "build_connector_solids", + # Ring generation + "create_ring_spine", + "create_ring_profile", + "create_ring_solid", + "create_female_hole_solid", + "compute_arc_attachment_point", + "add_female_holes_to_ring", + "trim_segment_against_ring", + "compute_ring_cuts_avoiding_holes", +] diff --git a/src/yapcad/manufacturing/connectors.py b/src/yapcad/manufacturing/connectors.py new file mode 100644 index 0000000..6a5d23e --- /dev/null +++ b/src/yapcad/manufacturing/connectors.py @@ -0,0 +1,513 @@ +"""Interior connector generation for beam segmentation. + +This module provides functions to create interior connectors that join +segmented swept elements. Connectors fit inside hollow profiles and +provide structural continuity across cut planes. +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + +from .path_utils import ( + extract_sub_path, + path_length, + length_to_parameter, + parameter_to_length, +) + + +# Default fit clearance values (mm per side) +FIT_CLEARANCE = { + 'press': 0.18, # Press-fit (structural) + 'slip': 0.30, # Slip-fit (easy assembly) + 'loose': 0.45, # Loose-fit (adjustable) +} + +# Connector length factor (multiple of largest profile dimension) +DEFAULT_LENGTH_FACTOR = 3.0 + + +def offset_rectangular_profile( + width: float, + height: float, + clearance: float +) -> Tuple[float, float]: + """Offset a rectangular profile inward by clearance. + + Args: + width: Original profile width + height: Original profile height + clearance: Amount to shrink per side + + Returns: + Tuple of (new_width, new_height) + + Raises: + ValueError: If clearance would result in zero or negative dimensions + """ + new_width = width - 2 * clearance + new_height = height - 2 * clearance + + if new_width <= 0 or new_height <= 0: + raise ValueError( + f"Clearance {clearance} too large for profile {width}x{height}. " + f"Would result in {new_width}x{new_height}" + ) + + return new_width, new_height + + +def compute_inner_profile_dimensions( + outer_width: float, + outer_height: float, + wall_thickness: float +) -> Tuple[float, float]: + """Compute inner void dimensions from outer profile and wall thickness. + + Args: + outer_width: Outer profile width + outer_height: Outer profile height + wall_thickness: Wall thickness + + Returns: + Tuple of (inner_width, inner_height) + """ + inner_width = outer_width - 2 * wall_thickness + inner_height = outer_height - 2 * wall_thickness + + if inner_width <= 0 or inner_height <= 0: + raise ValueError( + f"Wall thickness {wall_thickness} too large for profile " + f"{outer_width}x{outer_height}" + ) + + return inner_width, inner_height + + +def compute_connector_profile_dimensions( + outer_width: float, + outer_height: float, + wall_thickness: float, + fit_clearance: float +) -> Tuple[float, float]: + """Compute connector cross-section dimensions. + + The connector fits inside the hollow interior with specified clearance. + + Args: + outer_width: Outer profile width + outer_height: Outer profile height + wall_thickness: Wall thickness of hollow profile + fit_clearance: Clearance per side for desired fit + + Returns: + Tuple of (connector_width, connector_height) + """ + inner_w, inner_h = compute_inner_profile_dimensions( + outer_width, outer_height, wall_thickness + ) + return offset_rectangular_profile(inner_w, inner_h, fit_clearance) + + +def compute_connector_length( + profile_width: float, + profile_height: float, + spine: Dict[str, Any], + center_parameter: float, + *, + length_factor: float = DEFAULT_LENGTH_FACTOR, + min_arc_degrees: float = 15.0 +) -> float: + """Compute the appropriate connector length. + + For straight sections, length is based on profile size. + For curved sections, length may be extended to span a minimum arc. + + Args: + profile_width: Profile width + profile_height: Profile height + spine: Path3d dict + center_parameter: Where connector is centered (t in [0, 1]) + length_factor: Multiple of largest profile dimension + min_arc_degrees: Minimum arc span for curved sections + + Returns: + Connector length in mm + """ + max_dim = max(profile_width, profile_height) + base_length = length_factor * max_dim + + # Check curvature at cut point + # For now, use the base length + # Future: analyze local curvature and extend for tight curves + + return base_length + + +def create_connector_region2d( + width: float, + height: float, + *, + corner_radius: float = 0.0 +) -> List: + """Create a rectangular region2d for the connector profile. + + Args: + width: Connector width + height: Connector height + corner_radius: Optional fillet radius for corners + + Returns: + yapCAD region2d (list of polylines with proper winding) + """ + from yapcad.geom import line, point + + hw = width / 2 + hh = height / 2 + + if corner_radius > 0: + # Rectangle with filleted corners - use RoundRect from poly + from yapcad.poly import RoundRect + poly = RoundRect(width, height, corner_radius * 2) # chamf = diameter + outline = poly.geom # geom is a property that returns the geometry list + else: + # Simple rectangle centered at origin + # yapCAD points have format [x, y, z, 1] + outline = [ + line(point(-hw, -hh, 0), point(hw, -hh, 0)), + line(point(hw, -hh, 0), point(hw, hh, 0)), + line(point(hw, hh, 0), point(-hw, hh, 0)), + line(point(-hw, hh, 0), point(-hw, -hh, 0)), + ] + + return [outline] + + +def create_interior_connector( + outer_profile_width: float, + outer_profile_height: float, + spine: Dict[str, Any], + center_parameter: float, + *, + wall_thickness: float, + connector_length: Optional[float] = None, + fit_clearance: float = FIT_CLEARANCE['press'], + corner_radius: float = 0.0, +) -> Any: + """Create an interior connector solid. + + The connector fits inside the hollow interior of a swept beam element, + spanning across a cut plane to join two segments. + + Args: + outer_profile_width: Width of outer beam profile + outer_profile_height: Height of outer beam profile + spine: Path3d that the original beam follows + center_parameter: Location along spine (t in [0, 1]) where cut occurs + wall_thickness: Wall thickness of hollow beam + connector_length: Length of connector (auto-computed if None) + fit_clearance: Clearance for desired fit (mm per side) + corner_radius: Optional fillet radius for connector corners + + Returns: + yapCAD solid representing the connector + """ + from yapcad.geom3d_util import sweep_adaptive + + # Compute connector profile dimensions + conn_width, conn_height = compute_connector_profile_dimensions( + outer_profile_width, outer_profile_height, + wall_thickness, fit_clearance + ) + + # Compute connector length if not specified + if connector_length is None: + connector_length = compute_connector_length( + outer_profile_width, outer_profile_height, + spine, center_parameter + ) + + # Create connector profile (region2d) + connector_profile = create_connector_region2d( + conn_width, conn_height, corner_radius=corner_radius + ) + + # Extract spine segment for connector + # Connector extends half-length on each side of center + total_spine_length = path_length(spine) + half_connector_length = connector_length / 2 + + # Convert lengths to parameters + center_length = parameter_to_length(spine, center_parameter) + start_length = max(0, center_length - half_connector_length) + end_length = min(total_spine_length, center_length + half_connector_length) + + start_t = length_to_parameter(spine, start_length) + end_t = length_to_parameter(spine, end_length) + + # Extract sub-spine + connector_spine = extract_sub_path(spine, start_t, end_t) + + # Sweep connector profile along spine segment + # Note: sweep_adaptive expects the outer boundary polyline, not the full region2d + connector_solid = sweep_adaptive( + connector_profile[0], # Extract outer boundary polyline from region2d + connector_spine, + angle_threshold_deg=5.0 + ) + + return connector_solid + + +def compute_connector_spec( + element_id: str, + cut_parameter: float, + outer_width: float, + outer_height: float, + wall_thickness: float, + spine: Dict[str, Any], + *, + fit_clearance: float = FIT_CLEARANCE['press'], + connector_id: Optional[str] = None, +) -> 'ConnectorSpec': + """Compute full connector specification for a cut point. + + Args: + element_id: ID of parent swept element + cut_parameter: Where cut occurs (t in [0, 1]) + outer_width: Outer profile width + outer_height: Outer profile height + wall_thickness: Wall thickness + spine: Path3d of parent element + fit_clearance: Desired fit clearance + connector_id: Optional ID (auto-generated if None) + + Returns: + ConnectorSpec object with all computed values + """ + from .data import ConnectorSpec + + length = compute_connector_length( + outer_width, outer_height, spine, cut_parameter + ) + + if connector_id is None: + connector_id = f"{element_id}_conn_{int(cut_parameter * 100)}" + + return ConnectorSpec( + id=connector_id, + parent_element_id=element_id, + center_parameter=cut_parameter, + length=length, + fit_clearance=fit_clearance, + profile_type="box" + ) + + +def create_terminal_connector( + outer_profile_width: float, + outer_profile_height: float, + spine: Dict[str, Any], + end: str, # "start" or "end" + *, + wall_thickness: float, + connector_length: Optional[float] = None, + fit_clearance: float = FIT_CLEARANCE['press'], + corner_radius: float = 0.0, +) -> Any: + """Create a terminal connector tab at the start or end of a spine. + + Terminal connectors extend outward from arc endpoints and are designed + to slot into female holes in rings or other structures. + + Args: + outer_profile_width: Width of outer beam profile + outer_profile_height: Height of outer beam profile + spine: Path3d that the beam follows + end: "start" for t=0 end, "end" for t=1 end + wall_thickness: Wall thickness of hollow beam + connector_length: Length of connector tab (auto-computed if None) + fit_clearance: Clearance for desired fit (mm per side) + corner_radius: Optional fillet radius for connector corners + + Returns: + yapCAD solid representing the terminal connector tab + """ + from yapcad.geom3d_util import sweep_profile_along_path + + if end not in ("start", "end"): + raise ValueError(f"end must be 'start' or 'end', got '{end}'") + + # Compute connector profile dimensions (same as interior connector) + conn_width, conn_height = compute_connector_profile_dimensions( + outer_profile_width, outer_profile_height, + wall_thickness, fit_clearance + ) + + # Compute connector length if not specified + if connector_length is None: + # For terminal connectors, use half the interior connector length + # since they only extend one direction + connector_length = compute_connector_length( + outer_profile_width, outer_profile_height, + spine, 0.5 # Use midpoint for length calculation + ) / 2 + + # Create connector profile + connector_profile = create_connector_region2d( + conn_width, conn_height, corner_radius=corner_radius + ) + + # Extract endpoint position and tangent from spine + endpoint, tangent = _get_spine_endpoint_and_tangent(spine, end) + + # Create linear spine extending outward from endpoint + # Tangent points along spine direction, so: + # - At "start" (t=0), tangent points toward t=1, we want to extend OPPOSITE + # - At "end" (t=1), tangent points toward t=1 (continuation), we want same dir + if end == "start": + # Extend in opposite direction of tangent (outward from start) + direction = [-tangent[0], -tangent[1], -tangent[2]] + else: + # Extend in same direction as tangent (outward from end) + direction = tangent + + # Create linear path from endpoint extending outward + end_point = [ + endpoint[0] + direction[0] * connector_length, + endpoint[1] + direction[1] * connector_length, + endpoint[2] + direction[2] * connector_length, + ] + + # Build linear path3d as dict (same format as rings.py) + connector_spine = { + 'segments': [{ + 'type': 'line', + 'start': [endpoint[0], endpoint[1], endpoint[2]], + 'end': [end_point[0], end_point[1], end_point[2]] + }] + } + + # Sweep connector profile along linear spine + connector_solid = sweep_profile_along_path( + connector_profile[0], # Outer boundary polyline + connector_spine, + ) + + return connector_solid + + +def _get_spine_endpoint_and_tangent( + spine: Dict[str, Any], + end: str, +) -> Tuple[List[float], List[float]]: + """Extract endpoint position and tangent direction from spine. + + Args: + spine: Path3d dict + end: "start" for t=0, "end" for t=1 + + Returns: + Tuple of (position [x,y,z], tangent [dx,dy,dz] normalized) + """ + from .path_utils import evaluate_path3d_at_t + + if end == "start": + t = 0.0 + else: + t = 1.0 + + # Get position and tangent at endpoint + pos, tangent = evaluate_path3d_at_t(spine, t) + + # Normalize tangent (should already be normalized, but just in case) + mag = math.sqrt(tangent[0]**2 + tangent[1]**2 + tangent[2]**2) + if mag > 0: + tangent = [tangent[0]/mag, tangent[1]/mag, tangent[2]/mag] + + return pos, tangent + + +def add_terminal_connectors_to_segment( + segment_solid: Any, + provenance: 'SweptElementProvenance', + *, + add_start: bool = False, + add_end: bool = False, + connector_length: Optional[float] = None, + fit_clearance: float = FIT_CLEARANCE['press'], +) -> Any: + """Union terminal connector tabs with a segment's endpoints. + + Use this to add male tabs to the ends of arc segments that will + slot into female holes in rings or other structures. + + Args: + segment_solid: The segment solid to modify + provenance: SweptElementProvenance with profile and spine data + add_start: Add terminal tab at start (t=0) of spine + add_end: Add terminal tab at end (t=1) of spine + connector_length: Length of terminal tabs (auto-computed if None) + fit_clearance: Clearance for fit + + Returns: + Modified segment solid with terminal tabs unioned + + Raises: + ValueError: If provenance lacks required data + RuntimeError: If boolean union fails + """ + from yapcad.geom3d import solid_boolean + from yapcad.geom import geomlistbbox + + if not add_start and not add_end: + return segment_solid # Nothing to do + + # Get profile dimensions + outer_profile = provenance.outer_profile + if not outer_profile or not isinstance(outer_profile, list): + raise ValueError("Provenance must have valid outer_profile") + + outer = outer_profile[0] # First element is outer boundary + bbox = geomlistbbox(outer) + if not bbox or len(bbox) != 2: + raise ValueError("Cannot extract dimensions from outer_profile") + + outer_w = bbox[1][0] - bbox[0][0] + outer_h = bbox[1][1] - bbox[0][1] + + wall_thickness = provenance.wall_thickness + if wall_thickness is None: + raise ValueError("Wall thickness required for terminal connectors") + + result = segment_solid + + if add_start: + start_tab = create_terminal_connector( + outer_w, outer_h, + provenance.spine, + "start", + wall_thickness=wall_thickness, + connector_length=connector_length, + fit_clearance=fit_clearance, + ) + try: + result = solid_boolean(result, start_tab, 'union') + except Exception as e: + raise RuntimeError(f"Terminal connector union (start) failed: {e}") from e + + if add_end: + end_tab = create_terminal_connector( + outer_w, outer_h, + provenance.spine, + "end", + wall_thickness=wall_thickness, + connector_length=connector_length, + fit_clearance=fit_clearance, + ) + try: + result = solid_boolean(result, end_tab, 'union') + except Exception as e: + raise RuntimeError(f"Terminal connector union (end) failed: {e}") from e + + return result diff --git a/src/yapcad/manufacturing/data.py b/src/yapcad/manufacturing/data.py new file mode 100644 index 0000000..2c61a71 --- /dev/null +++ b/src/yapcad/manufacturing/data.py @@ -0,0 +1,147 @@ +"""Data structures for manufacturing post-processing. + +This module defines the core data structures used for beam segmentation +and assembly planning. +""" + +from dataclasses import dataclass, field +from typing import Any, List, Optional, Tuple + + +@dataclass +class CutPoint: + """Specification for a segmentation cut. + + Defines where and how to cut a swept element to create segments + that fit within build volume constraints. + + Attributes: + element_id: Identifier of the swept element to cut + parameter: Location along spine as t in [0, 1] + connector_length: Override for auto-computed connector length (mm) + fit_clearance: Dimensional offset for fit (mm per side) + union_connector_with: Which segment gets the connector tab + - "a": First segment (lower t) + - "b": Second segment (higher t) + - "none": Connector is separate piece + """ + element_id: str + parameter: float + connector_length: Optional[float] = None + fit_clearance: float = 0.2 + union_connector_with: str = "a" + + def __post_init__(self): + if not 0 < self.parameter < 1: + raise ValueError(f"Cut parameter must be in (0, 1), got {self.parameter}") + if self.union_connector_with not in ("a", "b", "none"): + raise ValueError(f"union_connector_with must be 'a', 'b', or 'none'") + + +@dataclass +class Segment: + """A segment resulting from splitting a swept element. + + Attributes: + id: Unique identifier for this segment + solid: yapCAD solid geometry + parent_element_id: ID of the original swept element + parameter_range: (t_start, t_end) along parent spine + has_connector_tab: True if connector is unioned with this segment + connector_type: Type of connector mating ("male", "female", "none") + - "male": Has protruding connector tab + - "female": Hollow interior receives male tab + - "none": No connector at this boundary + mates_with: IDs of segments this connects to + bounding_box: [[xmin,ymin,zmin,1], [xmax,ymax,zmax,1]] + """ + id: str + solid: Any # yapCAD solid + parent_element_id: str + parameter_range: Tuple[float, float] + has_connector_tab: bool = False + connector_type: str = "none" # "male", "female", or "none" + mates_with: List[str] = field(default_factory=list) + bounding_box: Optional[List] = None + + +@dataclass +class ConnectorSpec: + """Specification for an interior connector. + + Attributes: + id: Unique identifier + parent_element_id: ID of the swept element this connects + center_parameter: Location along spine where connector is centered + length: Total connector length (extends equally both sides of center) + fit_clearance: Dimensional offset applied to profile + profile_type: Type of connector profile ("box", "circular", "custom") + """ + id: str + parent_element_id: str + center_parameter: float + length: float + fit_clearance: float = 0.2 + profile_type: str = "box" + + +@dataclass +class SegmentationResult: + """Complete result of a segmentation operation. + + Attributes: + segments: List of resulting segment objects + connectors: List of connector specifications (for reference) + assembly_graph: Maps segment_id -> list of mating segment_ids + build_volume_ok: True if all segments fit target build volume + warnings: Any issues detected during segmentation + assembly_instructions: Human-readable assembly sequence + """ + segments: List[Segment] + connectors: List[ConnectorSpec] = field(default_factory=list) + assembly_graph: dict = field(default_factory=dict) + build_volume_ok: bool = True + warnings: List[str] = field(default_factory=list) + assembly_instructions: str = "" + + @property + def segment_count(self) -> int: + """Number of segments produced.""" + return len(self.segments) + + def get_segment(self, segment_id: str) -> Optional[Segment]: + """Get a segment by ID.""" + for seg in self.segments: + if seg.id == segment_id: + return seg + return None + + def get_segments_for_element(self, element_id: str) -> List[Segment]: + """Get all segments from a specific parent element.""" + return [s for s in self.segments if s.parent_element_id == element_id] + + +@dataclass +class SweptElementProvenance: + """Provenance data for a swept element. + + Captures how a swept solid was created, enabling intelligent + segmentation that preserves structural intent. + + Attributes: + id: Unique identifier for this element + operation: How it was created ("sweep", "sweep_adaptive", etc.) + outer_profile: The outer boundary region2d + inner_profile: Inner void region2d (None for solid profiles) + spine: The path3d the profile was swept along + wall_thickness: For hollow profiles, the wall thickness + semantic_type: Design intent ("structural_beam", "decorative", etc.) + """ + id: str + operation: str + outer_profile: Any # region2d + spine: Any # path3d + inner_profile: Optional[Any] = None # region2d or None + wall_thickness: Optional[float] = None + semantic_type: str = "structural_beam" + metadata: dict = field(default_factory=dict) diff --git a/src/yapcad/manufacturing/path_utils.py b/src/yapcad/manufacturing/path_utils.py new file mode 100644 index 0000000..a0463b9 --- /dev/null +++ b/src/yapcad/manufacturing/path_utils.py @@ -0,0 +1,380 @@ +"""Path3D utilities for manufacturing post-processing. + +This module provides functions for evaluating and manipulating path3d +objects, which are essential for beam segmentation operations. +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + + +def _normalize_vector(v: List[float]) -> List[float]: + """Normalize a 3D vector to unit length.""" + length = math.sqrt(v[0]**2 + v[1]**2 + v[2]**2) + if length < 1e-10: + return [0.0, 0.0, 1.0] # Default up vector + return [v[0]/length, v[1]/length, v[2]/length] + + +def _vector_subtract(a: List[float], b: List[float]) -> List[float]: + """Subtract two 3D vectors.""" + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + + +def _vector_add(a: List[float], b: List[float]) -> List[float]: + """Add two 3D vectors.""" + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] + + +def _vector_scale(v: List[float], s: float) -> List[float]: + """Scale a 3D vector.""" + return [v[0] * s, v[1] * s, v[2] * s] + + +def _cross_product(a: List[float], b: List[float]) -> List[float]: + """Cross product of two 3D vectors.""" + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + ] + + +def _lerp_point(a: List[float], b: List[float], t: float) -> List[float]: + """Linear interpolation between two points.""" + return [ + a[0] + t * (b[0] - a[0]), + a[1] + t * (b[1] - a[1]), + a[2] + t * (b[2] - a[2]) + ] + + +def _evaluate_line_segment( + start: List[float], + end: List[float], + t: float +) -> Tuple[List[float], List[float]]: + """Evaluate a line segment at parameter t. + + Args: + start: Start point [x, y, z] + end: End point [x, y, z] + t: Parameter in [0, 1] + + Returns: + Tuple of (point, tangent) where tangent is unit vector + """ + point = _lerp_point(start, end, t) + tangent = _normalize_vector(_vector_subtract(end, start)) + return point, tangent + + +def _evaluate_arc_segment( + center: List[float], + start: List[float], + end: List[float], + normal: List[float], + t: float +) -> Tuple[List[float], List[float]]: + """Evaluate an arc segment at parameter t. + + Args: + center: Arc center point + start: Start point on arc + end: End point on arc + normal: Arc plane normal (defines rotation direction) + t: Parameter in [0, 1] + + Returns: + Tuple of (point, tangent) where tangent is unit vector + """ + # Vector from center to start + r_start = _vector_subtract(start, center) + radius = math.sqrt(r_start[0]**2 + r_start[1]**2 + r_start[2]**2) + + # Vector from center to end + r_end = _vector_subtract(end, center) + + # Normalize radius vectors + r_start_norm = _normalize_vector(r_start) + r_end_norm = _normalize_vector(r_end) + + # Calculate angle using dot product + dot = r_start_norm[0]*r_end_norm[0] + r_start_norm[1]*r_end_norm[1] + r_start_norm[2]*r_end_norm[2] + dot = max(-1.0, min(1.0, dot)) + total_angle = math.acos(dot) + + # Check if we need to go the "long way" around + # Use cross product to determine direction + cross = _cross_product(r_start_norm, r_end_norm) + normal_norm = _normalize_vector(normal) + sign = cross[0]*normal_norm[0] + cross[1]*normal_norm[1] + cross[2]*normal_norm[2] + if sign < 0: + total_angle = 2 * math.pi - total_angle + + # Interpolate angle + angle = t * total_angle + + # Rodrigues' rotation formula to rotate r_start around normal + cos_a = math.cos(angle) + sin_a = math.sin(angle) + + # k x r (cross product of normal and radius vector) + k_cross_r = _cross_product(normal_norm, r_start_norm) + # k . r (dot product) + k_dot_r = normal_norm[0]*r_start_norm[0] + normal_norm[1]*r_start_norm[1] + normal_norm[2]*r_start_norm[2] + + # Rotated radius direction: r*cos(a) + (k x r)*sin(a) + k*(k.r)*(1-cos(a)) + rotated = [ + r_start_norm[i] * cos_a + k_cross_r[i] * sin_a + normal_norm[i] * k_dot_r * (1 - cos_a) + for i in range(3) + ] + + # Point on arc + point = _vector_add(center, _vector_scale(rotated, radius)) + + # Tangent is perpendicular to radius, in direction of rotation + # tangent = normal x rotated_radius_direction + tangent = _normalize_vector(_cross_product(normal_norm, rotated)) + + return point, tangent + + +def evaluate_path3d_at_t( + path3d: Dict[str, Any], + t: float +) -> Tuple[List[float], List[float]]: + """Evaluate a path3d at a global parameter value. + + Args: + path3d: Dict with 'segments' list of line/arc segments + t: Global parameter in [0, 1] across entire path + + Returns: + Tuple of (point, tangent) at parameter t + - point: [x, y, z] position on path + - tangent: [tx, ty, tz] unit tangent vector + + Raises: + ValueError: If path has no segments or t is out of range + """ + segments = path3d.get('segments', []) + if not segments: + raise ValueError("Path has no segments") + + # Clamp t to valid range + t = max(0.0, min(1.0, t)) + + # Convert global t to segment index and local t + n_segments = len(segments) + if t >= 1.0: + # At the end, evaluate last segment at t=1 + seg_idx = n_segments - 1 + local_t = 1.0 + else: + # Find which segment and local parameter + scaled = t * n_segments + seg_idx = int(scaled) + local_t = scaled - seg_idx + + seg = segments[seg_idx] + seg_type = seg.get('type', 'line') + + if seg_type == 'line': + return _evaluate_line_segment(seg['start'], seg['end'], local_t) + elif seg_type == 'arc': + normal = seg.get('normal', [0, 0, 1]) + return _evaluate_arc_segment( + seg['center'], seg['start'], seg['end'], normal, local_t + ) + else: + raise ValueError(f"Unknown segment type: {seg_type}") + + +def compute_cut_plane( + path3d: Dict[str, Any], + t: float +) -> Tuple[List[float], List[float]]: + """Compute the cut plane at a parameter along the path. + + The cut plane is perpendicular to the path tangent at the given parameter. + + Args: + path3d: Dict with 'segments' list + t: Global parameter in [0, 1] + + Returns: + Tuple of (point, normal) defining the plane + - point: A point on the plane (the path point at t) + - normal: Unit normal to the plane (the path tangent at t) + """ + point, tangent = evaluate_path3d_at_t(path3d, t) + return point, tangent + + +def extract_sub_path( + path3d: Dict[str, Any], + t_start: float, + t_end: float +) -> Dict[str, Any]: + """Extract a portion of a path3d between two parameters. + + Args: + path3d: Original path dict + t_start: Start parameter in [0, 1] + t_end: End parameter in [0, 1], must be > t_start + + Returns: + New path3d dict containing only the portion between t_start and t_end + + Raises: + ValueError: If t_start >= t_end or parameters out of range + """ + if t_start >= t_end: + raise ValueError(f"t_start ({t_start}) must be less than t_end ({t_end})") + + t_start = max(0.0, min(1.0, t_start)) + t_end = max(0.0, min(1.0, t_end)) + + segments = path3d.get('segments', []) + if not segments: + return {'segments': []} + + n_segments = len(segments) + new_segments = [] + + # Find start and end segment indices + start_scaled = t_start * n_segments + end_scaled = t_end * n_segments + + start_seg_idx = int(start_scaled) + end_seg_idx = min(int(end_scaled), n_segments - 1) + + start_local_t = start_scaled - start_seg_idx + end_local_t = end_scaled - end_seg_idx if end_seg_idx < n_segments else 1.0 + + for seg_idx in range(start_seg_idx, end_seg_idx + 1): + seg = segments[seg_idx] + seg_type = seg.get('type', 'line') + + # Determine local t range for this segment + if seg_idx == start_seg_idx: + local_t_min = start_local_t + else: + local_t_min = 0.0 + + if seg_idx == end_seg_idx: + local_t_max = end_local_t + else: + local_t_max = 1.0 + + # Skip if this segment contributes nothing + if local_t_min >= local_t_max: + continue + + # Extract the relevant portion + if seg_type == 'line': + new_start = _lerp_point(seg['start'], seg['end'], local_t_min) + new_end = _lerp_point(seg['start'], seg['end'], local_t_max) + new_segments.append({ + 'type': 'line', + 'start': new_start, + 'end': new_end + }) + elif seg_type == 'arc': + # For arcs, we need to find the points at the local parameters + normal = seg.get('normal', [0, 0, 1]) + new_start_pt, _ = _evaluate_arc_segment( + seg['center'], seg['start'], seg['end'], normal, local_t_min + ) + new_end_pt, _ = _evaluate_arc_segment( + seg['center'], seg['start'], seg['end'], normal, local_t_max + ) + new_segments.append({ + 'type': 'arc', + 'center': seg['center'], + 'start': new_start_pt, + 'end': new_end_pt, + 'normal': normal + }) + + return {'segments': new_segments} + + +def path_length(path3d: Dict[str, Any]) -> float: + """Compute the total arc length of a path3d. + + Args: + path3d: Dict with 'segments' list + + Returns: + Total length in same units as path coordinates + """ + segments = path3d.get('segments', []) + total = 0.0 + + for seg in segments: + seg_type = seg.get('type', 'line') + + if seg_type == 'line': + diff = _vector_subtract(seg['end'], seg['start']) + total += math.sqrt(diff[0]**2 + diff[1]**2 + diff[2]**2) + elif seg_type == 'arc': + # Arc length = radius * angle + center = seg['center'] + start = seg['start'] + end = seg['end'] + normal = seg.get('normal', [0, 0, 1]) + + r_start = _vector_subtract(start, center) + radius = math.sqrt(r_start[0]**2 + r_start[1]**2 + r_start[2]**2) + + r_end = _vector_subtract(end, center) + r_start_norm = _normalize_vector(r_start) + r_end_norm = _normalize_vector(r_end) + + dot = r_start_norm[0]*r_end_norm[0] + r_start_norm[1]*r_end_norm[1] + r_start_norm[2]*r_end_norm[2] + dot = max(-1.0, min(1.0, dot)) + angle = math.acos(dot) + + # Check direction + cross = _cross_product(r_start_norm, r_end_norm) + normal_norm = _normalize_vector(normal) + sign = cross[0]*normal_norm[0] + cross[1]*normal_norm[1] + cross[2]*normal_norm[2] + if sign < 0: + angle = 2 * math.pi - angle + + total += radius * angle + + return total + + +def length_to_parameter(path3d: Dict[str, Any], arc_length: float) -> float: + """Convert an arc length to a global parameter. + + Args: + path3d: Dict with 'segments' list + arc_length: Distance along the path from start + + Returns: + Global parameter t in [0, 1] + """ + total_length = path_length(path3d) + if total_length < 1e-10: + return 0.0 + return min(1.0, max(0.0, arc_length / total_length)) + + +def parameter_to_length(path3d: Dict[str, Any], t: float) -> float: + """Convert a global parameter to arc length. + + Args: + path3d: Dict with 'segments' list + t: Global parameter in [0, 1] + + Returns: + Arc length from path start to parameter t + """ + # Extract sub-path and compute its length + sub_path = extract_sub_path(path3d, 0.0, t) + return path_length(sub_path) diff --git a/src/yapcad/manufacturing/rings.py b/src/yapcad/manufacturing/rings.py new file mode 100644 index 0000000..0a3e283 --- /dev/null +++ b/src/yapcad/manufacturing/rings.py @@ -0,0 +1,724 @@ +"""Ring generation and female hole creation for manufacturing. + +This module provides functions to create base and cradle rings +with female holes that receive terminal connectors from arcs. +""" + +import math +from typing import Any, Dict, List, Optional, Tuple + +from .data import SweptElementProvenance +from .connectors import ( + FIT_CLEARANCE, + compute_connector_profile_dimensions, + create_connector_region2d, +) + + +def create_ring_spine( + radius: float, + center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + tilt_angle_deg: float = 0.0, + tilt_axis: str = "x", + num_segments: int = 72, +) -> Dict[str, Any]: + """Create a circular path3d for a ring. + + Args: + radius: Radius of the ring (to centerline of profile) + center: Center point of the ring (x, y, z) + tilt_angle_deg: Tilt angle in degrees (0 = horizontal) + tilt_axis: Axis to tilt around ("x", "y", or "z") + num_segments: Number of line segments to approximate the circle + + Returns: + yapCAD path3d dictionary with 'segments' key + """ + # Build ring as a sequence of line segments in XY plane + # This matches the path3d dict format used elsewhere in yapCAD + segments = [] + angle_step = 2 * math.pi / num_segments + + for i in range(num_segments): + angle_start = i * angle_step + angle_end = (i + 1) * angle_step + + # Start and end points on circle in XY plane + x0 = radius * math.cos(angle_start) + y0 = radius * math.sin(angle_start) + z0 = 0.0 + + x1 = radius * math.cos(angle_end) + y1 = radius * math.sin(angle_end) + z1 = 0.0 + + segments.append({ + 'type': 'line', + 'start': [x0, y0, z0], + 'end': [x1, y1, z1] + }) + + ring_path = {'segments': segments} + + # Apply tilt rotation if needed + if abs(tilt_angle_deg) > 0.001: + ring_path = _rotate_path3d(ring_path, tilt_angle_deg, tilt_axis) + + # Apply translation to center + if center != (0.0, 0.0, 0.0): + ring_path = _translate_path3d(ring_path, center) + + return ring_path + + +def _rotate_path3d( + path: Dict[str, Any], + angle_deg: float, + axis: str, +) -> Dict[str, Any]: + """Rotate a path3d around an axis through the origin. + + Args: + path: path3d dict with 'segments' key + angle_deg: rotation angle in degrees + axis: "x", "y", or "z" + + Returns: + Rotated path3d dict + """ + angle_rad = math.radians(angle_deg) + c = math.cos(angle_rad) + s = math.sin(angle_rad) + + def rotate_point(x, y, z): + if axis.lower() == "x": + return (x, y * c - z * s, y * s + z * c) + elif axis.lower() == "y": + return (x * c + z * s, y, -x * s + z * c) + else: # z axis + return (x * c - y * s, x * s + y * c, z) + + new_segments = [] + for seg in path.get('segments', []): + start = seg['start'] + end = seg['end'] + new_start = rotate_point(start[0], start[1], start[2]) + new_end = rotate_point(end[0], end[1], end[2]) + new_segments.append({ + 'type': seg['type'], + 'start': list(new_start), + 'end': list(new_end) + }) + + return {'segments': new_segments} + + +def _translate_path3d( + path: Dict[str, Any], + offset: Tuple[float, float, float], +) -> Dict[str, Any]: + """Translate a path3d by an offset. + + Args: + path: path3d dict with 'segments' key + offset: (dx, dy, dz) translation + + Returns: + Translated path3d dict + """ + dx, dy, dz = offset + + new_segments = [] + for seg in path.get('segments', []): + start = seg['start'] + end = seg['end'] + new_segments.append({ + 'type': seg['type'], + 'start': [start[0] + dx, start[1] + dy, start[2] + dz], + 'end': [end[0] + dx, end[1] + dy, end[2] + dz] + }) + + return {'segments': new_segments} + + +def create_ring_profile( + outer_width: float, + outer_height: float, + wall_thickness: float, +) -> List: + """Create a hollow rectangular profile (region2d) for a ring. + + Args: + outer_width: Width of outer profile + outer_height: Height of outer profile + wall_thickness: Wall thickness + + Returns: + yapCAD region2d (outer boundary, inner hole) + """ + from yapcad.geom import line, point + + # Outer rectangle + hw = outer_width / 2 + hh = outer_height / 2 + + outer = [ + line(point(-hw, -hh, 0), point(hw, -hh, 0)), + line(point(hw, -hh, 0), point(hw, hh, 0)), + line(point(hw, hh, 0), point(-hw, hh, 0)), + line(point(-hw, hh, 0), point(-hw, -hh, 0)), + ] + + # Inner rectangle (hole) + inner_hw = hw - wall_thickness + inner_hh = hh - wall_thickness + + if inner_hw <= 0 or inner_hh <= 0: + # Solid profile if wall too thick + return [outer] + + # Inner winding is opposite (clockwise for hole) + inner = [ + line(point(-inner_hw, -inner_hh, 0), point(-inner_hw, inner_hh, 0)), + line(point(-inner_hw, inner_hh, 0), point(inner_hw, inner_hh, 0)), + line(point(inner_hw, inner_hh, 0), point(inner_hw, -inner_hh, 0)), + line(point(inner_hw, -inner_hh, 0), point(-inner_hw, -inner_hh, 0)), + ] + + return [outer, inner] + + +def create_ring_solid( + radius: float, + outer_width: float, + outer_height: float, + wall_thickness: float, + center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + tilt_angle_deg: float = 0.0, + tilt_axis: str = "x", + ring_id: str = "ring", +) -> Tuple[Any, SweptElementProvenance]: + """Create a hollow box-beam ring with provenance tracking. + + Args: + radius: Radius to centerline of profile + outer_width: Width of beam profile + outer_height: Height of beam profile + wall_thickness: Wall thickness for hollow profile + center: Center point of ring + tilt_angle_deg: Tilt angle in degrees + tilt_axis: Axis to tilt around + ring_id: Identifier for the ring + + Returns: + Tuple of (ring_solid, SweptElementProvenance) + """ + from yapcad.geom3d_util import sweep_adaptive + + # Create ring spine + spine = create_ring_spine( + radius, center, tilt_angle_deg, tilt_axis + ) + + # Create hollow profile + profile = create_ring_profile(outer_width, outer_height, wall_thickness) + + # Sweep profile along ring spine + ring_solid = sweep_adaptive( + profile[0], # outer boundary + spine, + angle_threshold_deg=5.0, + inner_profiles=[profile[1]] if len(profile) > 1 else None, + ) + + # Create provenance + provenance = SweptElementProvenance( + id=ring_id, + operation="sweep_adaptive", + outer_profile=profile, + spine=spine, + inner_profile=profile[1] if len(profile) > 1 else None, + wall_thickness=wall_thickness, + semantic_type="ring", + metadata={ + 'solid': ring_solid, + 'radius': radius, + 'center': center, + 'tilt_angle_deg': tilt_angle_deg, + }, + ) + + return ring_solid, provenance + + +def create_female_hole_solid( + position: Tuple[float, float, float], + direction: Tuple[float, float, float], + outer_width: float, + outer_height: float, + wall_thickness: float, + hole_depth: float, + fit_clearance: float = FIT_CLEARANCE['press'], +) -> Any: + """Create a solid to subtract from a ring for a female connector hole. + + The hole allows a male terminal connector to slot in. + + Args: + position: Starting position of hole (on ring surface) + direction: Direction hole extends (into ring) + outer_width: Width of beam profile (for sizing hole) + outer_height: Height of beam profile (for sizing hole) + wall_thickness: Wall thickness of beam + hole_depth: How deep the hole extends + fit_clearance: Clearance to add for fit + + Returns: + yapCAD solid representing the hole volume to subtract + """ + from yapcad.geom3d_util import sweep_profile_along_path + + # Compute hole dimensions - should match connector profile but with + # small additional clearance for the hole itself + hole_clearance = fit_clearance / 2 # Extra clearance for hole + conn_width, conn_height = compute_connector_profile_dimensions( + outer_width, outer_height, wall_thickness, fit_clearance + ) + # Add extra clearance for the female hole + hole_width = conn_width + 2 * hole_clearance + hole_height = conn_height + 2 * hole_clearance + + # Create hole profile + hole_profile = create_connector_region2d(hole_width, hole_height) + + # Normalize direction + dx, dy, dz = direction + mag = math.sqrt(dx*dx + dy*dy + dz*dz) + if mag > 0: + dx, dy, dz = dx/mag, dy/mag, dz/mag + + # Create linear path for hole (using dict format) + end_pos = ( + position[0] + dx * hole_depth, + position[1] + dy * hole_depth, + position[2] + dz * hole_depth, + ) + + hole_spine = { + 'segments': [{ + 'type': 'line', + 'start': [position[0], position[1], position[2]], + 'end': [end_pos[0], end_pos[1], end_pos[2]] + }] + } + + # Sweep hole profile to create hole solid + hole_solid = sweep_profile_along_path( + hole_profile[0], # outer boundary + hole_spine, + ) + + return hole_solid + + +def compute_arc_attachment_point( + ring_radius: float, + attachment_angle_deg: float, + ring_center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + ring_tilt_deg: float = 0.0, + ring_tilt_axis: str = "x", +) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]: + """Compute where an arc attaches to a ring. + + Returns the position on the ring centerline and the radial direction + pointing inward (toward ring center). + + Args: + ring_radius: Radius of ring + attachment_angle_deg: Angle around ring where arc attaches + ring_center: Center of ring + ring_tilt_deg: Ring tilt angle + ring_tilt_axis: Axis ring is tilted around + + Returns: + Tuple of (position, inward_direction) + """ + # Compute position on untilted ring in XY plane + angle_rad = math.radians(attachment_angle_deg) + px = ring_radius * math.cos(angle_rad) + py = ring_radius * math.sin(angle_rad) + pz = 0.0 + + # Radial direction (pointing inward, toward center) + dx = -math.cos(angle_rad) + dy = -math.sin(angle_rad) + dz = 0.0 + + # Apply tilt rotation + if abs(ring_tilt_deg) > 0.001: + px, py, pz, dx, dy, dz = _rotate_point_and_direction( + px, py, pz, dx, dy, dz, ring_tilt_deg, ring_tilt_axis + ) + + # Apply translation + position = ( + px + ring_center[0], + py + ring_center[1], + pz + ring_center[2], + ) + + return position, (dx, dy, dz) + + +def _rotate_point_and_direction( + px: float, py: float, pz: float, + dx: float, dy: float, dz: float, + angle_deg: float, + axis: str, +) -> Tuple[float, float, float, float, float, float]: + """Rotate a point and direction vector around an axis.""" + angle_rad = math.radians(angle_deg) + c = math.cos(angle_rad) + s = math.sin(angle_rad) + + if axis.lower() == "x": + # Rotate around X axis + py_new = py * c - pz * s + pz_new = py * s + pz * c + py, pz = py_new, pz_new + + dy_new = dy * c - dz * s + dz_new = dy * s + dz * c + dy, dz = dy_new, dz_new + elif axis.lower() == "y": + # Rotate around Y axis + px_new = px * c + pz * s + pz_new = -px * s + pz * c + px, pz = px_new, pz_new + + dx_new = dx * c + dz * s + dz_new = -dx * s + dz * c + dx, dz = dx_new, dz_new + else: # z axis + # Rotate around Z axis + px_new = px * c - py * s + py_new = px * s + py * c + px, py = px_new, py_new + + dx_new = dx * c - dy * s + dy_new = dx * s + dy * c + dx, dy = dx_new, dy_new + + return px, py, pz, dx, dy, dz + + +def add_female_holes_to_ring( + ring_solid: Any, + attachment_points: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]], + outer_width: float, + outer_height: float, + wall_thickness: float, + hole_depth: Optional[float] = None, + fit_clearance: float = FIT_CLEARANCE['press'], +) -> Any: + """Subtract female holes at arc attachment positions. + + Args: + ring_solid: Ring solid to modify + attachment_points: List of (position, direction) tuples + outer_width: Beam profile width + outer_height: Beam profile height + wall_thickness: Wall thickness + hole_depth: Depth of holes (auto-computed if None) + fit_clearance: Fit clearance for holes + + Returns: + Ring solid with female holes subtracted + """ + from yapcad.geom3d import solid_boolean + + if hole_depth is None: + # Default hole depth matches terminal connector length + from .connectors import DEFAULT_LENGTH_FACTOR + hole_depth = max(outer_width, outer_height) * DEFAULT_LENGTH_FACTOR / 2 + + result = ring_solid + + for position, direction in attachment_points: + hole_solid = create_female_hole_solid( + position, + direction, + outer_width, + outer_height, + wall_thickness, + hole_depth, + fit_clearance, + ) + + try: + result = solid_boolean(result, hole_solid, 'difference') + except Exception as e: + # Log warning but continue + print(f"Warning: Failed to subtract hole at {position}: {e}") + + return result + + +def trim_segment_against_ring( + segment_solid: Any, + ring_solid: Any, +) -> Any: + """Trim a segment by subtracting the ring solid from it. + + This creates a clean interface where the arc meets the ring, + removing any overlap between the arc segment and ring geometry. + + Args: + segment_solid: Arc segment to trim + ring_solid: Ring solid to subtract + + Returns: + Trimmed segment solid + """ + from yapcad.geom3d import solid_boolean + from yapcad.brep import brep_from_solid, attach_brep_to_solid + + result = solid_boolean(segment_solid, ring_solid, 'difference') + + # Ensure BREP data is attached to the result + brep = brep_from_solid(result) + if brep is not None: + attach_brep_to_solid(result, brep) + + return result + + +def compute_ring_cuts_avoiding_holes( + ring_circumference: float, + max_segment_length: float, + hole_angles_deg: List[float], + min_distance_from_hole: float = 30.0, + *, + target_segments: Optional[int] = None, +) -> List[float]: + """Compute cut parameters for ring that avoid female hole positions. + + Produces evenly-spaced segments with cuts placed to avoid holes. + The algorithm finds cut positions that result in equal-length segments + (relative to t=0) while keeping cuts away from hole locations. + + For equal segments on a closed ring, we need cuts at positions that + divide [0, 1) into equal parts. With N segments, cuts should be at + 1/N, 2/N, ..., (N-1)/N. If holes are at these positions, we find + alternate cut positions that still produce equal segments. + + Args: + ring_circumference: Total circumference of ring in mm + max_segment_length: Maximum segment length in mm + hole_angles_deg: Angles where holes are located (0-360) + min_distance_from_hole: Minimum distance from hole center in mm + target_segments: If specified, use this many segments instead of + computing from max_segment_length. Useful when build volume + constraints allow fewer segments than the length calculation. + + Returns: + List of cut parameters (0-1) that avoid hole locations + """ + # Compute number of segments needed + if target_segments is not None: + num_segments = target_segments + else: + num_segments = math.ceil(ring_circumference / max_segment_length) + + if num_segments < 2: + return [] # No cuts needed + + num_cuts = num_segments - 1 + + # Convert hole angles to parameters (0-360 -> 0-1) + hole_params = sorted([angle / 360.0 for angle in hole_angles_deg]) + + # Convert min_distance to parameter space + min_param_distance = min_distance_from_hole / ring_circumference + + # Build exclusion zones around holes + exclusion_zones = [] + for hole_param in hole_params: + zone_start = hole_param - min_param_distance + zone_end = hole_param + min_param_distance + exclusion_zones.append((zone_start, zone_end)) + + def is_cut_valid(cut_param: float) -> bool: + """Check if a cut position avoids all holes.""" + for zone_start, zone_end in exclusion_zones: + # Handle wrap-around + if zone_start < 0: + # Zone wraps around 0 + if cut_param < zone_end or cut_param > (1.0 + zone_start): + return False + elif zone_end > 1.0: + # Zone wraps around 1 + if cut_param > zone_start or cut_param < (zone_end - 1.0): + return False + else: + # Normal case + if zone_start <= cut_param <= zone_end: + return False + return True + + def compute_segment_sizes(cuts: List[float]) -> List[float]: + """Compute segment sizes from sorted cut positions.""" + if not cuts: + return [1.0] + sorted_cuts = sorted(cuts) + sizes = [] + # First segment: from 0 to first cut + sizes.append(sorted_cuts[0]) + # Middle segments + for i in range(1, len(sorted_cuts)): + sizes.append(sorted_cuts[i] - sorted_cuts[i-1]) + # Last segment: from last cut to 1.0 + sizes.append(1.0 - sorted_cuts[-1]) + return sizes + + def segment_size_variance(cuts: List[float]) -> float: + """Compute variance of segment sizes (lower is better).""" + sizes = compute_segment_sizes(cuts) + mean_size = sum(sizes) / len(sizes) + return sum((s - mean_size) ** 2 for s in sizes) + + # For equal segments, cuts MUST be at exactly k/num_segments for k=1..num_cuts + # E.g., for 3 segments: cuts at 1/3 and 2/3 give equal segments [0,1/3], [1/3,2/3], [2/3,1] + # + # If holes block these positions, we find cuts as close as possible to minimize variance. + + segment_size = 1.0 / num_segments + + # Ideal cut positions for equal segments + ideal_cuts = [segment_size * (i + 1) for i in range(num_cuts)] + + # First, check if ideal cuts are all valid + if all(is_cut_valid(c) for c in ideal_cuts): + return ideal_cuts + + # Ideal cuts are blocked by holes. Find cuts as close as possible to ideal positions. + # Strategy: for each ideal cut position, find the nearest valid position. + + def find_nearest_valid_position(ideal_pos: float, search_range: float = 0.5) -> Optional[float]: + """Find the nearest valid position to ideal_pos within search_range.""" + if is_cut_valid(ideal_pos): + return ideal_pos + + # Search in both directions with fine resolution + best_pos = None + best_dist = float('inf') + + # Search 1000 positions within range + for i in range(1000): + offset = (i / 1000.0) * search_range + + # Try position above ideal + pos_above = ideal_pos + offset + if pos_above < 1.0 and is_cut_valid(pos_above): + dist = abs(pos_above - ideal_pos) + if dist < best_dist: + best_dist = dist + best_pos = pos_above + break # Found closest valid above + + # Try position below ideal + pos_below = ideal_pos - offset + if pos_below > 0.0 and is_cut_valid(pos_below): + dist = abs(pos_below - ideal_pos) + if dist < best_dist: + best_dist = dist + best_pos = pos_below + break # Found closest valid below + + return best_pos + + # Find nearest valid position for each ideal cut + adjusted_cuts = [] + for ideal_pos in ideal_cuts: + nearest = find_nearest_valid_position(ideal_pos) + if nearest is not None: + adjusted_cuts.append(nearest) + else: + # No valid position found for this cut - fall back to search + break + + if len(adjusted_cuts) == num_cuts: + # Verify no duplicates and proper ordering + adjusted_cuts = sorted(set(adjusted_cuts)) + if len(adjusted_cuts) == num_cuts: + return adjusted_cuts + + # Fallback: exhaustive search for minimum variance solution + # Find valid regions (gaps between exclusion zones) + exclusion_points = [] + for zone_start, zone_end in exclusion_zones: + if zone_start < 0: + exclusion_points.append((zone_start + 1.0, 1.0)) + exclusion_points.append((0.0, zone_end)) + elif zone_end > 1.0: + exclusion_points.append((zone_start, 1.0)) + exclusion_points.append((0.0, zone_end - 1.0)) + else: + exclusion_points.append((zone_start, zone_end)) + + exclusion_points.sort() + merged = [] + for start, end in exclusion_points: + if merged and start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + valid_regions = [] + if not merged: + valid_regions = [(0.0, 1.0)] + else: + if merged[0][0] > 0: + valid_regions.append((0.0, merged[0][0])) + for i in range(len(merged) - 1): + valid_regions.append((merged[i][1], merged[i+1][0])) + if merged[-1][1] < 1.0: + valid_regions.append((merged[-1][1], 1.0)) + + # Generate candidate positions: boundaries of valid regions and ideal cut positions + candidate_cuts = [] + + # Add boundary positions (just inside valid regions) + for region_start, region_end in valid_regions: + candidate_cuts.append(region_start + 0.001) + candidate_cuts.append(region_end - 0.001) + # Also add midpoint + candidate_cuts.append((region_start + region_end) / 2) + + # Add positions near ideal cuts + for ideal in ideal_cuts: + for offset in [0.0, 0.01, 0.02, 0.05, 0.1, -0.01, -0.02, -0.05, -0.1]: + pos = ideal + offset + if 0 < pos < 1: + candidate_cuts.append(pos) + + # Filter to valid positions only + candidate_cuts = sorted(set(c for c in candidate_cuts if 0 < c < 1 and is_cut_valid(c))) + + if len(candidate_cuts) >= num_cuts: + from itertools import combinations + best_cuts = None + best_variance = float('inf') + + for combo in combinations(candidate_cuts, num_cuts): + cuts = sorted(combo) + variance = segment_size_variance(cuts) + if variance < best_variance: + best_variance = variance + best_cuts = cuts + + if best_cuts: + return list(best_cuts) + + # Last resort: return evenly spaced cuts, even if they hit holes + return [segment_size * (i + 1) for i in range(num_cuts)] diff --git a/src/yapcad/manufacturing/segmentation.py b/src/yapcad/manufacturing/segmentation.py new file mode 100644 index 0000000..8eed60f --- /dev/null +++ b/src/yapcad/manufacturing/segmentation.py @@ -0,0 +1,816 @@ +"""Beam segmentation for manufacturing post-processing. + +This module provides functions to segment swept elements (beams, pipes) +at specified cut planes, creating printable segments with interior +connectors for reassembly. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from .data import ( + CutPoint, + Segment, + SegmentationResult, + ConnectorSpec, + SweptElementProvenance, +) +from .path_utils import ( + compute_cut_plane, + extract_sub_path, + path_length, +) +from .connectors import ( + FIT_CLEARANCE, + compute_connector_spec, + create_interior_connector, +) + + +def _fix_shape(shape): + """Apply OCC shape fixing to correct orientation and other issues. + + Ensures solids have outward-facing normals (matter inside). + + Args: + shape: OCC TopoDS_Shape to fix + + Returns: + Fixed shape with correct orientation + """ + from OCC.Core.ShapeFix import ShapeFix_Shape, ShapeFix_Solid + from OCC.Core.TopAbs import TopAbs_SOLID + from OCC.Core.BRepLib import breplib + from OCC.Core.TopoDS import topods + + # Apply general shape fixing first + fixer = ShapeFix_Shape(shape) + fixer.SetPrecision(1e-6) + fixer.SetMaxTolerance(1e-4) + fixer.SetMinTolerance(1e-8) + fixer.Perform() + fixed = fixer.Shape() + + # If it's a solid, ensure proper orientation (outward-facing normals) + if fixed.ShapeType() == TopAbs_SOLID: + try: + solid = topods.Solid(fixed) + # OrientClosedSolid orients the shell so matter is inside + # (i.e., normals point outward) + breplib.OrientClosedSolid(solid) + fixed = solid + except Exception: + # Fall back to ShapeFix_Solid if OrientClosedSolid fails + try: + solid_fixer = ShapeFix_Solid(fixed) + solid_fixer.SetPrecision(1e-6) + solid_fixer.Perform() + fixed = solid_fixer.Solid() + except Exception: + pass # Keep the shape-fixed result if all else fails + + return fixed + + +def extract_segment_between_planes( + solid: Any, + plane1_point: List[float], + plane1_normal: List[float], + plane2_point: List[float], + plane2_normal: List[float], +) -> Any: + """Extract a segment of a solid between two cutting planes. + + This is designed for closed rings where a single half-space cut doesn't + correctly identify the segment. We cut with both planes to isolate the + segment between them. + + The normals should point OUTWARD from the segment (toward the parts to remove). + + Args: + solid: yapCAD solid (typically a closed ring) + plane1_point: Point on first cutting plane + plane1_normal: Normal pointing away from segment (toward part to remove) + plane2_point: Point on second cutting plane + plane2_normal: Normal pointing away from segment (toward part to remove) + + Returns: + The segment between the two planes + """ + try: + from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Pln + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace + from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut + from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace + from yapcad.geom3d import solid as make_solid + from yapcad.brep import brep_from_solid, BrepSolid, attach_brep_to_solid + except ImportError as e: + raise RuntimeError( + "OCC (pythonocc-core) required for solid splitting" + ) from e + + brep = brep_from_solid(solid) + if brep is None or brep.shape is None: + raise ValueError("Could not extract BREP from solid") + + current_shape = brep.shape + + # Cut with first plane (remove the part where normal1 points) + pnt1 = gp_Pnt(plane1_point[0], plane1_point[1], plane1_point[2]) + dir1 = gp_Dir(plane1_normal[0], plane1_normal[1], plane1_normal[2]) + plane1 = gp_Pln(pnt1, dir1) + face1 = BRepBuilderAPI_MakeFace(plane1).Face() + + # Half-space on the side where normal points (the part to remove) + hs1 = BRepPrimAPI_MakeHalfSpace(face1, gp_Pnt( + plane1_point[0] + plane1_normal[0], + plane1_point[1] + plane1_normal[1], + plane1_point[2] + plane1_normal[2] + )) + if not hs1.IsDone(): + raise RuntimeError("Failed to create half-space 1") + + cut1 = BRepAlgoAPI_Cut(current_shape, hs1.Solid()) + if not cut1.IsDone(): + raise RuntimeError("First plane cut failed") + current_shape = cut1.Shape() + + # Cut with second plane (remove the part where normal2 points) + pnt2 = gp_Pnt(plane2_point[0], plane2_point[1], plane2_point[2]) + dir2 = gp_Dir(plane2_normal[0], plane2_normal[1], plane2_normal[2]) + plane2 = gp_Pln(pnt2, dir2) + face2 = BRepBuilderAPI_MakeFace(plane2).Face() + + hs2 = BRepPrimAPI_MakeHalfSpace(face2, gp_Pnt( + plane2_point[0] + plane2_normal[0], + plane2_point[1] + plane2_normal[1], + plane2_point[2] + plane2_normal[2] + )) + if not hs2.IsDone(): + raise RuntimeError("Failed to create half-space 2") + + cut2 = BRepAlgoAPI_Cut(current_shape, hs2.Solid()) + if not cut2.IsDone(): + raise RuntimeError("Second plane cut failed") + result_shape = cut2.Shape() + + # Fix the resulting shape + result_shape = _fix_shape(result_shape) + + # Wrap as yapCAD solid + result_brep = BrepSolid(result_shape) + result_solid = make_solid([], []) + attach_brep_to_solid(result_solid, result_brep) + + return result_solid + + +def segment_closed_ring( + solid: Any, + spine: Dict[str, Any], + cut_parameters: List[float], +) -> List[Any]: + """Segment a closed ring using two-plane extraction. + + For closed rings, single half-space cuts don't work correctly because + the geometry wraps around. This function uses two cutting planes per + segment to properly isolate each piece. + + Args: + solid: The complete ring solid + spine: Path3d representing the ring's sweep path + cut_parameters: Sorted list of t values where cuts occur (0 < t < 1) + + Returns: + List of segment solids in parameter order (segment 0 is [0, t0], etc.) + + Raises: + RuntimeError: If extraction fails + ValueError: If cut_parameters is empty or invalid + """ + if not cut_parameters: + raise ValueError("At least one cut parameter required") + + # Validate and sort cut parameters + cuts = sorted(cut_parameters) + for t in cuts: + if t <= 0 or t >= 1: + raise ValueError(f"Cut parameter {t} must be in range (0, 1)") + + segments = [] + n_cuts = len(cuts) + + # For a closed ring with cuts at [t1, t2, ...], we create segments: + # Segment 0: from t=0 to t=cuts[0] + # Segment 1: from t=cuts[0] to t=cuts[1] + # ... + # Segment n: from t=cuts[n-1] to t=1.0 (which wraps to 0) + + # Build list of segment boundaries + # Each segment is defined by [start_t, end_t] + segment_ranges = [] + segment_ranges.append((0.0, cuts[0])) # First segment + for i in range(n_cuts - 1): + segment_ranges.append((cuts[i], cuts[i + 1])) + segment_ranges.append((cuts[-1], 1.0)) # Last segment (wraps to start) + + for start_t, end_t in segment_ranges: + # Get cut planes + # For each segment, we need two planes that bound it + # The normal should point AWAY from the segment (toward part to remove) + + # Plane at start_t: normal points backward (toward lower t, away from segment) + start_point, start_normal = compute_cut_plane(spine, start_t) + # Flip normal to point backward (toward lower t values) + start_normal_out = [-start_normal[0], -start_normal[1], -start_normal[2]] + + # Plane at end_t: normal points forward (toward higher t, away from segment) + end_point, end_normal = compute_cut_plane(spine, end_t) + # Normal already points forward (toward higher t values) + end_normal_out = end_normal + + # Special handling for first segment (start_t = 0) + # and last segment (end_t = 1.0 which equals 0 for closed ring) + # For these, we still use the computed planes, but the geometry + # naturally handles the wrap-around + + try: + segment = extract_segment_between_planes( + solid, + start_point, start_normal_out, + end_point, end_normal_out, + ) + segments.append(segment) + except RuntimeError as e: + raise RuntimeError( + f"Failed to extract segment [{start_t}, {end_t}]: {e}" + ) from e + + return segments + + +def split_solid_at_plane( + solid: Any, + plane_point: List[float], + plane_normal: List[float], +) -> Tuple[Any, Any]: + """Split a solid into two parts at a plane. + + Uses OCC's boolean operations to cut the solid with a half-space + defined by the plane. + + NOTE: This works well for open paths (arcs, beams). For closed rings, + use segment_closed_ring() or extract_segment_between_planes() instead. + + Args: + solid: yapCAD solid to split + plane_point: [x, y, z] point on the cutting plane + plane_normal: [nx, ny, nz] normal vector (points toward "B" side) + + Returns: + Tuple of (solid_a, solid_b) where: + - solid_a: portion on negative side of plane (lower parameter) + - solid_b: portion on positive side of plane (higher parameter) + + Raises: + RuntimeError: If OCC is not available or splitting fails + """ + try: + from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Pln + from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace + from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut + from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace + from yapcad.geom3d import solid as make_solid + from yapcad.brep import brep_from_solid, BrepSolid, attach_brep_to_solid + except ImportError as e: + raise RuntimeError( + "OCC (pythonocc-core) required for solid splitting" + ) from e + + # Get BREP representation of input solid + brep = brep_from_solid(solid) + if brep is None or brep.shape is None: + raise ValueError("Could not extract BREP from solid") + + # Create cutting plane + pnt = gp_Pnt(plane_point[0], plane_point[1], plane_point[2]) + direction = gp_Dir(plane_normal[0], plane_normal[1], plane_normal[2]) + plane = gp_Pln(pnt, direction) + + # Create a face on the plane (needed for half-space) + face_builder = BRepBuilderAPI_MakeFace(plane) + if not face_builder.IsDone(): + raise RuntimeError("Failed to create cutting plane face") + plane_face = face_builder.Face() + + # Create half-spaces for both sides + # Half-space on positive side of plane (where normal points) + half_space_pos = BRepPrimAPI_MakeHalfSpace(plane_face, gp_Pnt( + plane_point[0] + plane_normal[0], + plane_point[1] + plane_normal[1], + plane_point[2] + plane_normal[2] + )) + if not half_space_pos.IsDone(): + raise RuntimeError("Failed to create positive half-space") + + # Half-space on negative side of plane + half_space_neg = BRepPrimAPI_MakeHalfSpace(plane_face, gp_Pnt( + plane_point[0] - plane_normal[0], + plane_point[1] - plane_normal[1], + plane_point[2] - plane_normal[2] + )) + if not half_space_neg.IsDone(): + raise RuntimeError("Failed to create negative half-space") + + # Cut solid with each half-space to get the two parts + # solid_a = solid ∩ negative_half_space (cut away positive side) + cut_a = BRepAlgoAPI_Cut(brep.shape, half_space_pos.Solid()) + if not cut_a.IsDone(): + raise RuntimeError("Boolean cut for segment A failed") + shape_a = cut_a.Shape() + + # solid_b = solid ∩ positive_half_space (cut away negative side) + cut_b = BRepAlgoAPI_Cut(brep.shape, half_space_neg.Solid()) + if not cut_b.IsDone(): + raise RuntimeError("Boolean cut for segment B failed") + shape_b = cut_b.Shape() + + # Fix shapes to correct face orientations after boolean operations + shape_a = _fix_shape(shape_a) + shape_b = _fix_shape(shape_b) + + # Wrap results as yapCAD solids with BREP using standard storage method + # This ensures entityId is created and BREP is properly serialized/cached + brep_a = BrepSolid(shape_a) + solid_a = make_solid([], []) + attach_brep_to_solid(solid_a, brep_a) + + brep_b = BrepSolid(shape_b) + solid_b = make_solid([], []) + attach_brep_to_solid(solid_b, brep_b) + + return solid_a, solid_b + + +def _union_connector_with_segment( + segment_solid: Any, + connector_solid: Any, +) -> Any: + """Union a connector with a segment solid to create a male joint. + + Args: + segment_solid: The segment to union connector with + connector_solid: The interior connector solid + + Returns: + Resulting solid with connector unioned + + Raises: + RuntimeError: If union operation fails + """ + from yapcad.geom3d import solid_boolean + from yapcad.brep import brep_from_solid, BrepSolid, attach_brep_to_solid + from yapcad.geom3d import solid as make_solid + + try: + result = solid_boolean(segment_solid, connector_solid, 'union') + + # Apply shape fixing to the result to ensure correct face orientations + brep = brep_from_solid(result) + if brep is not None and brep.shape is not None: + fixed_shape = _fix_shape(brep.shape) + fixed_brep = BrepSolid(fixed_shape) + fixed_solid = make_solid([], []) + attach_brep_to_solid(fixed_solid, fixed_brep) + return fixed_solid + + return result + except Exception as e: + raise RuntimeError(f"Connector union failed: {e}") from e + + +def segment_swept_element( + provenance: SweptElementProvenance, + cut_points: List[CutPoint], + *, + build_connectors: bool = True, + union_connectors: bool = True, +) -> SegmentationResult: + """Segment a swept element at the specified cut points. + + Takes a swept element (beam, pipe, etc.) with its provenance data + and produces segments that can be individually manufactured and + reassembled. + + Args: + provenance: SweptElementProvenance with profile, spine, and metadata + cut_points: List of CutPoint specifying where to cut + build_connectors: Whether to generate interior connector solids + union_connectors: Whether to union connectors with segments (male/female) + When True, connectors are integrated into segments based on + CutPoint.union_connector_with. When False, connectors are separate. + + Returns: + SegmentationResult with segments, connectors, and assembly info + """ + # Validate inputs + if not cut_points: + raise ValueError("At least one cut point required") + + # Sort cut points by parameter + sorted_cuts = sorted(cut_points, key=lambda cp: cp.parameter) + + # Validate cut points are for this element + for cp in sorted_cuts: + if cp.element_id != provenance.id: + raise ValueError( + f"Cut point element_id '{cp.element_id}' doesn't match " + f"provenance id '{provenance.id}'" + ) + + # We need the solid - rebuild it from provenance if needed + # For now, assume provenance has metadata['solid'] from original creation + solid = provenance.metadata.get('solid') + if solid is None: + raise ValueError( + "Provenance must include 'solid' in metadata for segmentation" + ) + + # Get profile dimensions upfront if building connectors + outer_w, outer_h = None, None + if build_connectors and provenance.wall_thickness is not None: + outer_w, outer_h = _extract_profile_dimensions(provenance.outer_profile) + + # Generate segments by iterating through cuts + segments: List[Segment] = [] + connectors: List[ConnectorSpec] = [] + assembly_graph: Dict[str, List[str]] = {} + warnings: List[str] = [] + + # Track remaining solid as we make cuts + remaining_solid = solid + prev_segment_id: Optional[str] = None + segment_start_t = 0.0 + + # Track connector type for segments + # When union_connector_with=="b", the next segment gets the connector + next_segment_has_connector = False + + for i, cut_point in enumerate(sorted_cuts): + # Compute cut plane from spine + plane_point, plane_normal = compute_cut_plane( + provenance.spine, cut_point.parameter + ) + + # Split the remaining solid + try: + segment_solid, remaining_solid = split_solid_at_plane( + remaining_solid, plane_point, plane_normal + ) + except RuntimeError as e: + warnings.append(f"Cut {i} at t={cut_point.parameter} failed: {e}") + continue + + # Determine this segment's connector type based on previous cut's decision + # and build connector for this cut + segment_connector_type = "none" + segment_has_tab = False + + # If previous cut designated "b" (remaining), this segment has the connector + if next_segment_has_connector: + segment_connector_type = "male" + segment_has_tab = True + next_segment_has_connector = False + + # Build and integrate connector for THIS cut + if build_connectors and outer_w and outer_h and provenance.wall_thickness: + # Build connector specification + conn_spec = compute_connector_spec( + provenance.id, + cut_point.parameter, + outer_w, + outer_h, + provenance.wall_thickness, + provenance.spine, + fit_clearance=cut_point.fit_clearance, + ) + + if union_connectors and cut_point.union_connector_with != "none": + # Build actual connector solid + connector_solid = create_interior_connector( + outer_w, + outer_h, + provenance.spine, + cut_point.parameter, + wall_thickness=provenance.wall_thickness, + connector_length=conn_spec.length, + fit_clearance=cut_point.fit_clearance, + ) + + if cut_point.union_connector_with == "a": + # Union connector with segment A (this segment) + try: + segment_solid = _union_connector_with_segment( + segment_solid, connector_solid + ) + segment_connector_type = "male" + segment_has_tab = True + except RuntimeError as e: + warnings.append( + f"Connector union at t={cut_point.parameter} failed: {e}" + ) + # Fall back to separate connector + connectors.append(conn_spec) + + elif cut_point.union_connector_with == "b": + # Union connector with segment B (remaining/next segment) + try: + remaining_solid = _union_connector_with_segment( + remaining_solid, connector_solid + ) + next_segment_has_connector = True + # This segment (A) is female - receives the connector from B + if segment_connector_type == "none": + segment_connector_type = "female" + except RuntimeError as e: + warnings.append( + f"Connector union at t={cut_point.parameter} failed: {e}" + ) + connectors.append(conn_spec) + else: + # Keep connector as separate piece + connectors.append(conn_spec) + + # Create segment + segment_id = f"{provenance.id}_seg_{i}" + segment = Segment( + id=segment_id, + solid=segment_solid, + parent_element_id=provenance.id, + parameter_range=(segment_start_t, cut_point.parameter), + has_connector_tab=segment_has_tab, + connector_type=segment_connector_type, + ) + + # Track mating relationships + if prev_segment_id: + segment.mates_with.append(prev_segment_id) + # Update previous segment's mates + prev_seg = next( + (s for s in segments if s.id == prev_segment_id), None + ) + if prev_seg: + prev_seg.mates_with.append(segment_id) + # If this segment is male and prev segment doesn't have a tab, + # prev segment becomes female at this joint + if segment_connector_type == "male" and prev_seg.connector_type == "none": + prev_seg.connector_type = "female" + + segments.append(segment) + assembly_graph[segment_id] = list(segment.mates_with) + + prev_segment_id = segment_id + segment_start_t = cut_point.parameter + + # Final segment (from last cut to end) + final_segment_id = f"{provenance.id}_seg_{len(sorted_cuts)}" + + # Check if final segment has connector from last cut's "b" designation + final_has_tab = next_segment_has_connector + + # Determine final segment's connector type + if next_segment_has_connector: + # Last cut used union_connector_with="b", so final segment is male + final_connector_type = "male" + elif prev_segment_id: + # Check if previous segment is male - if so, final segment receives its tab + prev_seg = next((s for s in segments if s.id == prev_segment_id), None) + if prev_seg and prev_seg.connector_type == "male": + final_connector_type = "female" + else: + final_connector_type = "none" + else: + final_connector_type = "none" + + final_segment = Segment( + id=final_segment_id, + solid=remaining_solid, + parent_element_id=provenance.id, + parameter_range=(segment_start_t, 1.0), + has_connector_tab=final_has_tab, + connector_type=final_connector_type, + ) + + if prev_segment_id: + final_segment.mates_with.append(prev_segment_id) + prev_seg = next((s for s in segments if s.id == prev_segment_id), None) + if prev_seg: + prev_seg.mates_with.append(final_segment_id) + + segments.append(final_segment) + assembly_graph[final_segment_id] = list(final_segment.mates_with) + + # Generate assembly instructions + instructions = _generate_assembly_instructions(segments, connectors) + + return SegmentationResult( + segments=segments, + connectors=connectors, + assembly_graph=assembly_graph, + build_volume_ok=True, # TODO: validate against target volume + warnings=warnings, + assembly_instructions=instructions, + ) + + +def _extract_profile_dimensions( + profile: Any, +) -> Tuple[Optional[float], Optional[float]]: + """Extract width and height from a rectangular region2d profile. + + Args: + profile: yapCAD region2d + + Returns: + Tuple of (width, height) or (None, None) if not extractable + """ + if not profile: + return None, None + + try: + from yapcad.geom import geomlistbbox + + # Get bounding box of outer profile boundary + if isinstance(profile, list) and len(profile) > 0: + outer = profile[0] # First element is outer boundary + bbox = geomlistbbox(outer) + if bbox and len(bbox) == 2: + width = bbox[1][0] - bbox[0][0] + height = bbox[1][1] - bbox[0][1] + return width, height + except Exception: + pass + + return None, None + + +def _generate_assembly_instructions( + segments: List[Segment], + connectors: List[ConnectorSpec], +) -> str: + """Generate human-readable assembly instructions. + + Args: + segments: List of segment objects + connectors: List of connector specifications (may be empty if integrated) + + Returns: + Multi-line string with assembly steps + """ + lines = ["Assembly Instructions", "=" * 21, ""] + + lines.append(f"Total segments: {len(segments)}") + + # Count male/female segments + male_count = sum(1 for s in segments if s.connector_type == "male") + female_count = sum(1 for s in segments if s.connector_type == "female") + + if male_count > 0 or female_count > 0: + lines.append(f"Male segments (with integrated connectors): {male_count}") + lines.append(f"Female segments (receive connectors): {female_count}") + if connectors: + lines.append(f"Separate connectors: {len(connectors)}") + lines.append("") + + lines.append("Assembly Sequence:") + lines.append("-" * 18) + + for i, seg in enumerate(segments): + step_num = i + 1 + lines.append(f"{step_num}. Place segment '{seg.id}'") + + # Show connector type if applicable + if seg.connector_type == "male": + lines.append(" Type: MALE (has integrated connector tab)") + elif seg.connector_type == "female": + lines.append(" Type: FEMALE (receives connector from mate)") + + if seg.mates_with: + mates = ", ".join(seg.mates_with) + lines.append(f" Connects to: {mates}") + + # Describe mating + for mate_id in seg.mates_with: + mate_seg = next((s for s in segments if s.id == mate_id), None) + if mate_seg: + if seg.connector_type == "male" and mate_seg.connector_type == "female": + lines.append(f" -> Insert tab into '{mate_id}'") + elif seg.connector_type == "female" and mate_seg.connector_type == "male": + lines.append(f" -> Receive tab from '{mate_id}'") + + # Check for separate connectors at this joint + for conn in connectors: + seg_end = seg.parameter_range[1] + if abs(conn.center_parameter - seg_end) < 0.01: + lines.append(f" Insert connector '{conn.id}' before next segment") + lines.append(f" Fit type: {_fit_type_description(conn.fit_clearance)}") + + lines.append("") + + return "\n".join(lines) + + +def _fit_type_description(clearance: float) -> str: + """Convert clearance value to human-readable fit type.""" + if clearance <= FIT_CLEARANCE['press']: + return "press-fit (structural)" + elif clearance <= FIT_CLEARANCE['slip']: + return "slip-fit (easy assembly)" + else: + return "loose-fit (adjustable)" + + +def compute_optimal_cuts( + provenance: SweptElementProvenance, + max_segment_length: float, + *, + prefer_straight_cuts: bool = True, +) -> List[CutPoint]: + """Compute optimal cut locations for a given max segment length. + + Analyzes the spine and proposes cut locations that: + - Keep segments under max_segment_length + - Prefer cuts at straight sections (not mid-curve) + - Maintain structural integrity + + Args: + provenance: SweptElementProvenance with spine data + max_segment_length: Maximum length of any single segment + prefer_straight_cuts: Try to cut at straight sections when possible + + Returns: + List of CutPoint at optimal locations + """ + spine = provenance.spine + total_length = path_length(spine) + + if total_length <= max_segment_length: + # No cuts needed + return [] + + # Simple approach: evenly spaced cuts + # TODO: Enhanced version would analyze curvature and avoid mid-arc cuts + num_segments = int(total_length / max_segment_length) + 1 + segment_length = total_length / num_segments + + cut_points = [] + for i in range(1, num_segments): + # Convert length to parameter + cut_length = i * segment_length + cut_t = cut_length / total_length + + cut_points.append(CutPoint( + element_id=provenance.id, + parameter=cut_t, + fit_clearance=FIT_CLEARANCE['press'], + )) + + return cut_points + + +def build_connector_solids( + provenance: SweptElementProvenance, + connector_specs: List[ConnectorSpec], +) -> List[Tuple[ConnectorSpec, Any]]: + """Build actual connector solids from specifications. + + Args: + provenance: SweptElementProvenance with profile and spine data + connector_specs: List of ConnectorSpec from segmentation + + Returns: + List of (ConnectorSpec, solid) tuples + """ + results = [] + + # Get profile dimensions + outer_w, outer_h = _extract_profile_dimensions(provenance.outer_profile) + if outer_w is None or outer_h is None: + raise ValueError("Cannot extract profile dimensions from provenance") + + wall_thickness = provenance.wall_thickness + if wall_thickness is None: + raise ValueError("Wall thickness required for connector generation") + + for spec in connector_specs: + connector_solid = create_interior_connector( + outer_w, + outer_h, + provenance.spine, + spec.center_parameter, + wall_thickness=wall_thickness, + connector_length=spec.length, + fit_clearance=spec.fit_clearance, + ) + results.append((spec, connector_solid)) + + return results diff --git a/src/yapcad/package/viewer.py b/src/yapcad/package/viewer.py index f1fa16d..a7ea0df 100644 --- a/src/yapcad/package/viewer.py +++ b/src/yapcad/package/viewer.py @@ -87,7 +87,7 @@ def _tessellate_brep(brep_base64: str) -> List[Tuple[Tuple[float, float, float], try: from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.TopExp import TopExp_Explorer - from OCC.Core.TopAbs import TopAbs_FACE + from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_REVERSED from OCC.Core.TopLoc import TopLoc_Location from OCC.Core.BRep import BRep_Tool from OCC.Core.TopoDS import topods @@ -119,6 +119,11 @@ def _tessellate_brep(brep_base64: str) -> List[Tuple[Tuple[float, float, float], if triangulation is not None: transform = location.Transformation() + # Check face orientation - REVERSED faces need normal negation + # In OCC, a REVERSED face means the face's outward normal is opposite + # to the geometric normal computed from the triangulation + is_reversed = (face.Orientation() == TopAbs_REVERSED) + for i in range(1, triangulation.NbTriangles() + 1): tri = triangulation.Triangle(i) n1, n2, n3 = tri.Get() @@ -130,7 +135,7 @@ def _tessellate_brep(brep_base64: str) -> List[Tuple[Tuple[float, float, float], p.Transform(transform) pts.append((p.X(), p.Y(), p.Z())) - # Compute face normal from triangle + # Compute face normal from triangle cross product v1 = (pts[1][0] - pts[0][0], pts[1][1] - pts[0][1], pts[1][2] - pts[0][2]) v2 = (pts[2][0] - pts[0][0], pts[2][1] - pts[0][1], pts[2][2] - pts[0][2]) nx = v1[1]*v2[2] - v1[2]*v2[1] @@ -142,6 +147,10 @@ def _tessellate_brep(brep_base64: str) -> List[Tuple[Tuple[float, float, float], else: nx, ny, nz = 0, 0, 1 + # For REVERSED faces, negate the normal to get correct outward direction + if is_reversed: + nx, ny, nz = -nx, -ny, -nz + normal = (nx, ny, nz) for pt in pts: triangles.append((pt, normal)) @@ -320,6 +329,10 @@ def __init__(self, bucket_triangles: Dict[str, List[Tuple[Tuple[float, float, fl self.clip_y = 0 # Y clipping plane (XZ plane) self.clip_z = 0 # Z clipping plane (XY plane) self._clip_state_names = ["off", "+", "-"] + self.show_normals = False # Normal visualization mode + # Compute normal display length based on model size + model_size = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]) + self._normal_length = model_size * 0.02 # 2% of model size glEnable(GL_DEPTH_TEST) glEnable(GL_LIGHTING) glEnable(GL_LIGHT0) @@ -459,6 +472,70 @@ def _disable_clipping_planes(self) -> None: glDisable(GL_CLIP_PLANE1) glDisable(GL_CLIP_PLANE2) + def _draw_normals(self) -> None: + """Draw face normals as line segments from face centers.""" + if not self.show_normals: + return + + glDisable(GL_LIGHTING) + glLineWidth(1.5) + + # Draw normals for each visible bucket + for bucket in self.bucket_names: + if not self.visible_buckets.get(bucket, True): + continue + + tris = self.bucket_triangles.get(bucket, []) + if not tris: + continue + + # Process triangles (3 vertices per triangle) + glBegin(GL_LINES) + for i in range(0, len(tris), 3): + if i + 2 >= len(tris): + break + + # Get the 3 vertices and their normals + v0, n0 = tris[i] + v1, n1 = tris[i + 1] + v2, n2 = tris[i + 2] + + # Compute face center + cx = (v0[0] + v1[0] + v2[0]) / 3.0 + cy = (v0[1] + v1[1] + v2[1]) / 3.0 + cz = (v0[2] + v1[2] + v2[2]) / 3.0 + + # Average normal (they should all be the same for flat shading) + nx = (n0[0] + n1[0] + n2[0]) / 3.0 + ny = (n0[1] + n1[1] + n2[1]) / 3.0 + nz = (n0[2] + n1[2] + n2[2]) / 3.0 + + # Normalize + mag = (nx*nx + ny*ny + nz*nz) ** 0.5 + if mag > 1e-10: + nx, ny, nz = nx/mag, ny/mag, nz/mag + else: + continue + + # Compute endpoint + length = self._normal_length + ex = cx + nx * length + ey = cy + ny * length + ez = cz + nz * length + + # Draw line from center to endpoint + # Base of normal: cyan + glColor4f(0.0, 1.0, 1.0, 1.0) + glVertex3f(cx, cy, cz) + # Tip of normal: magenta (shows direction clearly) + glColor4f(1.0, 0.0, 1.0, 1.0) + glVertex3f(ex, ey, ez) + + glEnd() + + glLineWidth(1.0) + glEnable(GL_LIGHTING) + def _draw_triangles(self): mode = self.render_mode @@ -495,6 +572,9 @@ def _draw_triangles(self): glDisable(GL_POLYGON_OFFSET_LINE) glEnable(GL_LIGHTING) + # Draw face normals if enabled + self._draw_normals() + # Disable clipping planes after drawing self._disable_clipping_planes() @@ -644,6 +724,10 @@ def _draw_axes_overlay(self, x: int, y: int, width: int, height: int, text: str, if clip_parts: axis_lines.append("Clip: " + ", ".join(clip_parts)) + # Show normals status if enabled + if self.show_normals: + axis_lines.append("Normals: ON (N to toggle)") + if len(self.layer_names) > 1: layer_display = [] for idx, layer in enumerate(self.layer_names, start=1): @@ -718,6 +802,7 @@ def _draw_help_overlay(self, fb_width: int, fb_height: int) -> None: if self.clip_z != 0: clip_status.append(f"Z:{self._clip_state_names[self.clip_z]}") clip_str = ", ".join(clip_status) if clip_status else "none" + normals_str = "ON" if self.show_normals else "off" help_lines = [ "Viewer Controls", f"Current view: {view_name}", @@ -732,6 +817,9 @@ def _draw_help_overlay(self, fb_width: int, fb_height: int) -> None: " X – cycle X clip (off → + → −), Y – Y clip, Z – Z clip", " C – clear all clipping planes", f" Active: {clip_str}", + "Normals (for diagnosing orientation):", + " N – toggle face normal visualization (cyan→magenta)", + f" Status: {normals_str}", "Layers:", " Number keys 1-9 toggle layers, 0 resets", f" Active: {active_layers}", @@ -822,6 +910,9 @@ def on_key_press(self, symbol, modifiers): self.clip_x = 0 self.clip_y = 0 self.clip_z = 0 + elif symbol == key.N: + # N toggles normal visualization + self.show_normals = not self.show_normals def on_close(self): for vlist in self._bucket_vertex_lists.values(): diff --git a/tests/test_dsl_packaging.py b/tests/test_dsl_packaging.py new file mode 100644 index 0000000..2a977ee --- /dev/null +++ b/tests/test_dsl_packaging.py @@ -0,0 +1,304 @@ +""" +Tests for DSL-to-Package integration (yapcad.dsl.packaging). +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +from yapcad.dsl.packaging import ( + package_from_dsl, + PackageResult, + _get_yapcad_version, + _add_dsl_source_attachment, +) + + +class TestPackageResult: + """Test the PackageResult class.""" + + def test_success_result(self): + """PackageResult with success=True.""" + mock_manifest = Mock() + mock_manifest.root = Path("/tmp/test_pkg") + + result = PackageResult( + success=True, + manifest=mock_manifest, + ) + + assert result.success is True + assert result.manifest is mock_manifest + assert result.error_message is None + + def test_failure_result(self): + """PackageResult with success=False.""" + result = PackageResult( + success=False, + error_message="Test error message", + ) + + assert result.success is False + assert result.manifest is None + assert result.error_message == "Test error message" + + def test_repr_success(self): + """Test __repr__ for successful result.""" + mock_manifest = Mock() + result = PackageResult(success=True, manifest=mock_manifest) + repr_str = repr(result) + assert "success=True" in repr_str + + def test_repr_failure(self): + """Test __repr__ for failed result.""" + result = PackageResult(success=False, error_message="failed") + repr_str = repr(result) + assert "success=False" in repr_str + assert "failed" in repr_str + + +class TestGetYapcadVersion: + """Test the _get_yapcad_version helper.""" + + def test_version_available(self): + """Returns version when yapcad.__version__ exists.""" + with patch('yapcad.dsl.packaging._get_yapcad_version') as mock: + mock.return_value = "1.2.3" + # Call the actual function to test the import logic + version = _get_yapcad_version() + # Version should be a string (either actual version or "unknown") + assert isinstance(version, str) + + def test_version_import_error(self): + """Returns 'unknown' when import fails.""" + # The actual function handles ImportError internally + version = _get_yapcad_version() + assert isinstance(version, str) + + +class TestPackageFromDsl: + """Test the package_from_dsl function.""" + + def test_dsl_execution_failure(self): + """Returns error when DSL execution fails.""" + with patch('yapcad.dsl.packaging.compile_and_run') as mock_run: + mock_result = Mock() + mock_result.success = False + mock_result.error_message = "Syntax error at line 1" + mock_run.return_value = mock_result + + result = package_from_dsl( + source="invalid dsl", + command_name="TEST", + parameters={}, + target_dir="/tmp/test", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is False + assert "DSL execution failed" in result.error_message + assert "Syntax error" in result.error_message + + def test_no_geometry_output(self): + """Returns error when DSL produces no geometry.""" + with patch('yapcad.dsl.packaging.compile_and_run') as mock_run: + mock_result = Mock() + mock_result.success = True + mock_result.geometry = None + mock_run.return_value = mock_result + + result = package_from_dsl( + source="module test;", + command_name="TEST", + parameters={}, + target_dir="/tmp/test", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is False + assert "no geometry output" in result.error_message.lower() + + def test_unexpected_geometry_type(self): + """Returns error for unexpected geometry type.""" + with patch('yapcad.dsl.packaging.compile_and_run') as mock_run: + with patch('yapcad.geom3d.issolid', return_value=False): + with patch('yapcad.geom3d.issurface', return_value=False): + mock_result = Mock() + mock_result.success = True + mock_result.geometry = "not a geometry object" + mock_run.return_value = mock_result + + result = package_from_dsl( + source="module test;", + command_name="TEST", + parameters={}, + target_dir="/tmp/test", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is False + assert "Unexpected geometry type" in result.error_message + + def test_successful_packaging(self, tmp_path): + """Successfully creates package from DSL.""" + mock_solid = Mock() + mock_manifest = Mock() + mock_manifest.root = tmp_path / "test_pkg" + mock_manifest.data = {} + + mock_exec_result = Mock() + mock_exec_result.success = True + mock_exec_result.geometry = mock_solid + mock_exec_result.provenance = None + + with patch('yapcad.dsl.packaging.compile_and_run', return_value=mock_exec_result): + with patch('yapcad.geom3d.issolid', return_value=True): + with patch('yapcad.package.create_package_from_entities', return_value=mock_manifest) as mock_create: + with patch('yapcad.dsl.packaging._add_dsl_source_attachment'): + result = package_from_dsl( + source="module test; command MAKE() -> solid { emit box(1,1,1); }", + command_name="MAKE", + parameters={}, + target_dir=tmp_path / "output", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is True + assert result.manifest is mock_manifest + mock_create.assert_called_once() + + def test_provenance_metadata(self, tmp_path): + """Generator metadata includes provenance when available.""" + mock_solid = Mock() + mock_manifest = Mock() + mock_manifest.root = tmp_path / "test_pkg" + mock_manifest.data = {} + + mock_provenance = Mock() + mock_provenance.module_name = "test_module" + mock_provenance.command_name = "MAKE_THING" + mock_provenance.parameters = {"size": 10} + mock_provenance.source_signature = "abc123" + mock_provenance.version = "1.0" + + mock_exec_result = Mock() + mock_exec_result.success = True + mock_exec_result.geometry = mock_solid + mock_exec_result.provenance = mock_provenance + + with patch('yapcad.dsl.packaging.compile_and_run', return_value=mock_exec_result): + with patch('yapcad.geom3d.issolid', return_value=True): + with patch('yapcad.package.create_package_from_entities', return_value=mock_manifest) as mock_create: + with patch('yapcad.dsl.packaging._add_dsl_source_attachment'): + result = package_from_dsl( + source="test source", + command_name="MAKE_THING", + parameters={"size": 10}, + target_dir=tmp_path / "output", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is True + # Check that generator metadata was passed + call_kwargs = mock_create.call_args[1] + generator = call_kwargs.get('generator', {}) + assert generator.get('tool') == 'yapCAD-DSL' + assert 'dsl' in generator + assert generator['dsl']['module'] == 'test_module' + + def test_package_creation_exception(self, tmp_path): + """Handles exception during package creation.""" + mock_solid = Mock() + + mock_exec_result = Mock() + mock_exec_result.success = True + mock_exec_result.geometry = mock_solid + mock_exec_result.provenance = None + + with patch('yapcad.dsl.packaging.compile_and_run', return_value=mock_exec_result): + with patch('yapcad.geom3d.issolid', return_value=True): + with patch('yapcad.package.create_package_from_entities', side_effect=Exception("Disk full")): + result = package_from_dsl( + source="test source", + command_name="MAKE", + parameters={}, + target_dir=tmp_path / "output", + name="test_pkg", + version="1.0.0", + ) + + assert result.success is False + assert "Package creation failed" in result.error_message + assert "Disk full" in result.error_message + + +class TestAddDslSourceAttachment: + """Test the _add_dsl_source_attachment helper.""" + + def test_creates_attachment_file(self, tmp_path): + """Creates source.dsl file in attachments directory.""" + mock_manifest = Mock() + mock_manifest.root = tmp_path + mock_manifest.data = {} + + source_code = "module test;\ncommand MAKE() -> solid { emit box(1,1,1); }" + + _add_dsl_source_attachment(mock_manifest, source_code, "MAKE") + + # Check file was created + source_file = tmp_path / "attachments" / "source.dsl" + assert source_file.exists() + assert source_file.read_text() == source_code + + def test_adds_attachment_to_manifest(self, tmp_path): + """Adds attachment entry to manifest data.""" + mock_manifest = Mock() + mock_manifest.root = tmp_path + mock_manifest.data = {} + + source_code = "module test;" + + _add_dsl_source_attachment(mock_manifest, source_code, "TEST_CMD") + + # Check manifest was updated + attachments = mock_manifest.data.get("attachments", []) + assert len(attachments) == 1 + attachment = attachments[0] + assert attachment["id"] == "dsl-source" + assert attachment["path"] == "attachments/source.dsl" + assert attachment["format"] == "dsl" + assert "TEST_CMD" in attachment["purpose"] + assert attachment["hash"].startswith("sha256:") + + # Verify save was called + mock_manifest.save.assert_called_once() + + +class TestModuleImports: + """Test that module imports work correctly.""" + + def test_import_package_result(self): + """PackageResult can be imported from yapcad.dsl.""" + from yapcad.dsl import PackageResult + assert PackageResult is not None + + def test_import_package_from_dsl(self): + """package_from_dsl can be imported from yapcad.dsl.""" + from yapcad.dsl import package_from_dsl + assert callable(package_from_dsl) + + def test_type_annotations_valid(self): + """Type annotations don't cause import errors.""" + # This test verifies the TYPE_CHECKING fix works + # If PackageManifest wasn't properly handled, this would fail + import yapcad.dsl.packaging as pkg + + # Check that the module loaded without errors + assert hasattr(pkg, 'package_from_dsl') + assert hasattr(pkg, 'PackageResult') + assert hasattr(pkg, '_add_dsl_source_attachment') diff --git a/tests/test_dsl_parser.py b/tests/test_dsl_parser.py index 7bf92d0..501d6e1 100644 --- a/tests/test_dsl_parser.py +++ b/tests/test_dsl_parser.py @@ -571,3 +571,16 @@ def test_unexpected_token(self): """Unexpected token raises error.""" with pytest.raises(ParserError): parse_source("command T() -> int { emit %; }") + + def test_while_loop_deprecated(self): + """While loops raise helpful deprecation error.""" + source = """ + def test() -> int: + while True: + pass + """ + with pytest.raises(ParserError) as exc_info: + parse_source(source) + error_msg = str(exc_info.value).lower() + assert "while" in error_msg + assert "not supported" in error_msg or "static verifiability" in error_msg diff --git a/tests/test_manufacturing.py b/tests/test_manufacturing.py new file mode 100644 index 0000000..dcd39d1 --- /dev/null +++ b/tests/test_manufacturing.py @@ -0,0 +1,574 @@ +"""Tests for the manufacturing post-processing module. + +Tests cover path utilities, connector calculations, data structures, +and (with OCC) full solid segmentation. +""" + +import math +import pytest + +from yapcad.manufacturing import ( + # Data structures + CutPoint, + Segment, + SegmentationResult, + ConnectorSpec, + SweptElementProvenance, + # Path utilities + evaluate_path3d_at_t, + compute_cut_plane, + extract_sub_path, + path_length, + length_to_parameter, + parameter_to_length, + # Connector functions + FIT_CLEARANCE, + offset_rectangular_profile, + compute_inner_profile_dimensions, + compute_connector_profile_dimensions, + compute_connector_length, + create_connector_region2d, + # Segmentation + compute_optimal_cuts, +) + + +# ============================================================================ +# Test fixtures +# ============================================================================ + +@pytest.fixture +def simple_line_path(): + """A simple straight line path from origin to (100, 0, 0).""" + return { + 'segments': [ + { + 'type': 'line', + 'start': [0.0, 0.0, 0.0], + 'end': [100.0, 0.0, 0.0], + } + ] + } + + +@pytest.fixture +def two_segment_path(): + """A path with two line segments forming an L-shape.""" + return { + 'segments': [ + { + 'type': 'line', + 'start': [0.0, 0.0, 0.0], + 'end': [50.0, 0.0, 0.0], + }, + { + 'type': 'line', + 'start': [50.0, 0.0, 0.0], + 'end': [50.0, 50.0, 0.0], + }, + ] + } + + +@pytest.fixture +def arc_path(): + """A simple 90-degree arc in the XY plane.""" + return { + 'segments': [ + { + 'type': 'arc', + 'center': [0.0, 0.0, 0.0], + 'start': [10.0, 0.0, 0.0], + 'end': [0.0, 10.0, 0.0], + 'normal': [0.0, 0.0, 1.0], + } + ] + } + + +# ============================================================================ +# Path utility tests +# ============================================================================ + +class TestPathEvaluation: + """Tests for path3d evaluation functions.""" + + def test_evaluate_line_start(self, simple_line_path): + """Evaluate at start of line.""" + point, tangent = evaluate_path3d_at_t(simple_line_path, 0.0) + assert point == pytest.approx([0.0, 0.0, 0.0], abs=1e-10) + assert tangent == pytest.approx([1.0, 0.0, 0.0], abs=1e-10) + + def test_evaluate_line_middle(self, simple_line_path): + """Evaluate at middle of line.""" + point, tangent = evaluate_path3d_at_t(simple_line_path, 0.5) + assert point == pytest.approx([50.0, 0.0, 0.0], abs=1e-10) + assert tangent == pytest.approx([1.0, 0.0, 0.0], abs=1e-10) + + def test_evaluate_line_end(self, simple_line_path): + """Evaluate at end of line.""" + point, tangent = evaluate_path3d_at_t(simple_line_path, 1.0) + assert point == pytest.approx([100.0, 0.0, 0.0], abs=1e-10) + assert tangent == pytest.approx([1.0, 0.0, 0.0], abs=1e-10) + + def test_evaluate_two_segment_first(self, two_segment_path): + """Evaluate in first segment of two-segment path.""" + point, tangent = evaluate_path3d_at_t(two_segment_path, 0.25) + # t=0.25 is halfway through first segment (which goes 0 to 0.5) + assert point == pytest.approx([25.0, 0.0, 0.0], abs=1e-10) + assert tangent == pytest.approx([1.0, 0.0, 0.0], abs=1e-10) + + def test_evaluate_two_segment_second(self, two_segment_path): + """Evaluate in second segment of two-segment path.""" + point, tangent = evaluate_path3d_at_t(two_segment_path, 0.75) + # t=0.75 is halfway through second segment + assert point == pytest.approx([50.0, 25.0, 0.0], abs=1e-10) + assert tangent == pytest.approx([0.0, 1.0, 0.0], abs=1e-10) + + def test_evaluate_arc_start(self, arc_path): + """Evaluate at start of arc.""" + point, tangent = evaluate_path3d_at_t(arc_path, 0.0) + assert point == pytest.approx([10.0, 0.0, 0.0], abs=1e-6) + # Tangent at start of 90-degree CCW arc is in +Y direction + assert tangent == pytest.approx([0.0, 1.0, 0.0], abs=1e-6) + + def test_evaluate_arc_middle(self, arc_path): + """Evaluate at middle of 90-degree arc.""" + point, tangent = evaluate_path3d_at_t(arc_path, 0.5) + # At 45 degrees, point is at (r*cos(45), r*sin(45), 0) + r = 10.0 + expected_coord = r * math.cos(math.pi / 4) + assert point == pytest.approx([expected_coord, expected_coord, 0.0], abs=1e-6) + + def test_evaluate_arc_end(self, arc_path): + """Evaluate at end of 90-degree arc.""" + point, tangent = evaluate_path3d_at_t(arc_path, 1.0) + assert point == pytest.approx([0.0, 10.0, 0.0], abs=1e-6) + # Tangent at end of CCW arc (at 90 degrees) is in -X direction + assert tangent == pytest.approx([-1.0, 0.0, 0.0], abs=1e-6) + + +class TestPathLength: + """Tests for path length calculations.""" + + def test_line_length(self, simple_line_path): + """Length of simple line.""" + length = path_length(simple_line_path) + assert length == pytest.approx(100.0, abs=1e-10) + + def test_two_segment_length(self, two_segment_path): + """Length of L-shaped path.""" + length = path_length(two_segment_path) + assert length == pytest.approx(100.0, abs=1e-10) # 50 + 50 + + def test_arc_length(self, arc_path): + """Length of 90-degree arc.""" + length = path_length(arc_path) + expected = 10.0 * (math.pi / 2) # radius * angle + assert length == pytest.approx(expected, abs=1e-6) + + +class TestSubPathExtraction: + """Tests for extracting portions of paths.""" + + def test_extract_first_half(self, simple_line_path): + """Extract first half of line.""" + sub = extract_sub_path(simple_line_path, 0.0, 0.5) + assert len(sub['segments']) == 1 + seg = sub['segments'][0] + assert seg['start'] == pytest.approx([0.0, 0.0, 0.0], abs=1e-10) + assert seg['end'] == pytest.approx([50.0, 0.0, 0.0], abs=1e-10) + + def test_extract_second_half(self, simple_line_path): + """Extract second half of line.""" + sub = extract_sub_path(simple_line_path, 0.5, 1.0) + assert len(sub['segments']) == 1 + seg = sub['segments'][0] + assert seg['start'] == pytest.approx([50.0, 0.0, 0.0], abs=1e-10) + assert seg['end'] == pytest.approx([100.0, 0.0, 0.0], abs=1e-10) + + def test_extract_middle_portion(self, simple_line_path): + """Extract middle portion of line.""" + sub = extract_sub_path(simple_line_path, 0.25, 0.75) + assert len(sub['segments']) == 1 + seg = sub['segments'][0] + assert seg['start'] == pytest.approx([25.0, 0.0, 0.0], abs=1e-10) + assert seg['end'] == pytest.approx([75.0, 0.0, 0.0], abs=1e-10) + + def test_extract_across_segments(self, two_segment_path): + """Extract portion spanning both segments.""" + sub = extract_sub_path(two_segment_path, 0.25, 0.75) + assert len(sub['segments']) == 2 + + def test_invalid_range(self, simple_line_path): + """Error when t_start >= t_end.""" + with pytest.raises(ValueError): + extract_sub_path(simple_line_path, 0.5, 0.5) + with pytest.raises(ValueError): + extract_sub_path(simple_line_path, 0.7, 0.3) + + +class TestLengthParameterConversion: + """Tests for converting between arc length and parameter.""" + + def test_length_to_param_line(self, simple_line_path): + """Convert length to parameter on line.""" + t = length_to_parameter(simple_line_path, 50.0) + assert t == pytest.approx(0.5, abs=1e-10) + + def test_param_to_length_line(self, simple_line_path): + """Convert parameter to length on line.""" + length = parameter_to_length(simple_line_path, 0.5) + assert length == pytest.approx(50.0, abs=1e-10) + + def test_roundtrip(self, simple_line_path): + """Convert parameter -> length -> parameter.""" + original_t = 0.37 + length = parameter_to_length(simple_line_path, original_t) + recovered_t = length_to_parameter(simple_line_path, length) + assert recovered_t == pytest.approx(original_t, abs=1e-10) + + +class TestCutPlane: + """Tests for cut plane computation.""" + + def test_cut_plane_line(self, simple_line_path): + """Cut plane on line is perpendicular.""" + point, normal = compute_cut_plane(simple_line_path, 0.5) + assert point == pytest.approx([50.0, 0.0, 0.0], abs=1e-10) + # Normal equals tangent (pointing along path) + assert normal == pytest.approx([1.0, 0.0, 0.0], abs=1e-10) + + +# ============================================================================ +# Connector calculation tests +# ============================================================================ + +class TestConnectorProfiles: + """Tests for connector profile dimension calculations.""" + + def test_offset_rectangular_profile(self): + """Offset profile shrinks by clearance per side.""" + new_w, new_h = offset_rectangular_profile(10.0, 20.0, 0.2) + assert new_w == pytest.approx(9.6, abs=1e-10) + assert new_h == pytest.approx(19.6, abs=1e-10) + + def test_offset_too_large(self): + """Error when clearance is too large.""" + with pytest.raises(ValueError, match="too large"): + offset_rectangular_profile(1.0, 1.0, 0.6) + + def test_compute_inner_profile(self): + """Inner profile from outer and wall thickness.""" + inner_w, inner_h = compute_inner_profile_dimensions(20.0, 30.0, 2.0) + assert inner_w == pytest.approx(16.0, abs=1e-10) + assert inner_h == pytest.approx(26.0, abs=1e-10) + + def test_compute_connector_profile(self): + """Full connector profile calculation.""" + conn_w, conn_h = compute_connector_profile_dimensions( + outer_width=20.0, + outer_height=30.0, + wall_thickness=2.0, + fit_clearance=0.2, + ) + # Inner is 16x26, minus 0.4 on each dimension + assert conn_w == pytest.approx(15.6, abs=1e-10) + assert conn_h == pytest.approx(25.6, abs=1e-10) + + def test_connector_length_default(self, simple_line_path): + """Default connector length based on profile size.""" + length = compute_connector_length(20.0, 30.0, simple_line_path, 0.5) + # Default factor is 3.0, max dimension is 30 + assert length == pytest.approx(90.0, abs=1e-10) + + +class TestFitClearances: + """Tests for fit clearance values.""" + + def test_fit_clearance_values(self): + """Verify standard fit clearance values.""" + assert FIT_CLEARANCE['press'] == pytest.approx(0.18, abs=0.01) + assert FIT_CLEARANCE['slip'] == pytest.approx(0.30, abs=0.01) + assert FIT_CLEARANCE['loose'] == pytest.approx(0.45, abs=0.01) + + def test_fit_ordering(self): + """Press < slip < loose.""" + assert FIT_CLEARANCE['press'] < FIT_CLEARANCE['slip'] + assert FIT_CLEARANCE['slip'] < FIT_CLEARANCE['loose'] + + +class TestConnectorRegion: + """Tests for connector region2d creation.""" + + def test_create_simple_region(self): + """Create rectangular connector region.""" + region = create_connector_region2d(10.0, 20.0) + assert isinstance(region, list) + assert len(region) == 1 # Single outer boundary + + def test_create_rounded_region(self): + """Create rounded-corner connector region.""" + region = create_connector_region2d(10.0, 20.0, corner_radius=1.0) + assert isinstance(region, list) + assert len(region) == 1 + + +# ============================================================================ +# Data structure tests +# ============================================================================ + +class TestCutPoint: + """Tests for CutPoint data structure.""" + + def test_valid_cut_point(self): + """Create valid cut point.""" + cp = CutPoint("beam1", 0.5) + assert cp.element_id == "beam1" + assert cp.parameter == 0.5 + assert cp.fit_clearance == 0.2 # Default + + def test_invalid_parameter_zero(self): + """Cannot cut at t=0.""" + with pytest.raises(ValueError): + CutPoint("beam1", 0.0) + + def test_invalid_parameter_one(self): + """Cannot cut at t=1.""" + with pytest.raises(ValueError): + CutPoint("beam1", 1.0) + + def test_invalid_union_with(self): + """Invalid union_connector_with value.""" + with pytest.raises(ValueError): + CutPoint("beam1", 0.5, union_connector_with="invalid") + + +class TestSegmentationResult: + """Tests for SegmentationResult data structure.""" + + def test_segment_count(self): + """Segment count property.""" + result = SegmentationResult( + segments=[ + Segment("seg1", None, "beam1", (0, 0.5)), + Segment("seg2", None, "beam1", (0.5, 1)), + ] + ) + assert result.segment_count == 2 + + def test_get_segment(self): + """Get segment by ID.""" + seg1 = Segment("seg1", None, "beam1", (0, 0.5)) + seg2 = Segment("seg2", None, "beam1", (0.5, 1)) + result = SegmentationResult(segments=[seg1, seg2]) + + found = result.get_segment("seg1") + assert found is seg1 + + not_found = result.get_segment("nonexistent") + assert not_found is None + + def test_get_segments_for_element(self): + """Get all segments for a parent element.""" + result = SegmentationResult( + segments=[ + Segment("seg1", None, "beam1", (0, 0.5)), + Segment("seg2", None, "beam1", (0.5, 1)), + Segment("seg3", None, "beam2", (0, 1)), + ] + ) + beam1_segs = result.get_segments_for_element("beam1") + assert len(beam1_segs) == 2 + assert all(s.parent_element_id == "beam1" for s in beam1_segs) + + +class TestSweptElementProvenance: + """Tests for SweptElementProvenance data structure.""" + + def test_create_provenance(self, simple_line_path): + """Create basic provenance object.""" + prov = SweptElementProvenance( + id="beam1", + operation="sweep_adaptive", + outer_profile=[[...]], # placeholder + spine=simple_line_path, + wall_thickness=2.0, + ) + assert prov.id == "beam1" + assert prov.semantic_type == "structural_beam" # Default + + +# ============================================================================ +# Optimal cuts tests +# ============================================================================ + +class TestOptimalCuts: + """Tests for automatic cut point computation.""" + + def test_no_cuts_needed(self, simple_line_path): + """No cuts when beam fits in build volume.""" + prov = SweptElementProvenance( + id="beam1", + operation="sweep", + outer_profile=[[...]], + spine=simple_line_path, + ) + cuts = compute_optimal_cuts(prov, max_segment_length=150.0) + assert len(cuts) == 0 + + def test_one_cut_needed(self, simple_line_path): + """One cut when beam is 2x max length.""" + prov = SweptElementProvenance( + id="beam1", + operation="sweep", + outer_profile=[[...]], + spine=simple_line_path, # 100mm path + ) + cuts = compute_optimal_cuts(prov, max_segment_length=60.0) + # Path is 100mm, max is 60mm, so need at least 2 segments + assert len(cuts) == 1 + assert 0 < cuts[0].parameter < 1 + + def test_multiple_cuts_needed(self, simple_line_path): + """Multiple cuts for long beam.""" + prov = SweptElementProvenance( + id="beam1", + operation="sweep", + outer_profile=[[...]], + spine=simple_line_path, # 100mm path + ) + cuts = compute_optimal_cuts(prov, max_segment_length=30.0) + # Need 4 segments (100/30 = 3.3, rounded up to 4) + assert len(cuts) == 3 + # Cuts should be roughly evenly spaced + params = [c.parameter for c in cuts] + assert all(0 < p < 1 for p in params) + assert params == sorted(params) # In order + + +# ============================================================================ +# Integration tests (require OCC) +# ============================================================================ + +def has_occ(): + """Check if OCC is available.""" + try: + from OCC.Core.gp import gp_Pnt + return True + except ImportError: + return False + + +@pytest.mark.skipif(not has_occ(), reason="OCC not available") +@pytest.mark.skip(reason="OCC integration tests need profile/path format refinement") +class TestSolidSegmentation: + """Integration tests requiring OCC for solid operations. + + NOTE: These tests are currently skipped because the profile and path + format conversion between manufacturing module format and sweep_adaptive + requires further refinement. The core path utilities and data structures + are tested separately. + """ + + def test_split_solid_basic(self): + """Split a simple box solid.""" + from yapcad.manufacturing import split_solid_at_plane + from yapcad.geom3d_util import prism + + # Create a 100x20x20 box (prism creates box centered at origin) + box = prism(100, 20, 20) + + # Split at the middle + solid_a, solid_b = split_solid_at_plane( + box, + plane_point=[50.0, 0.0, 0.0], + plane_normal=[1.0, 0.0, 0.0], + ) + + # Both parts should exist + assert solid_a is not None + assert solid_b is not None + + # Both should have BREP data (metadata is at index 4) + assert len(solid_a) == 5 and 'brep' in solid_a[4] + assert len(solid_b) == 5 and 'brep' in solid_b[4] + + def test_create_interior_connector(self, simple_line_path): + """Create an interior connector solid.""" + from yapcad.manufacturing import create_interior_connector + + connector = create_interior_connector( + outer_profile_width=20.0, + outer_profile_height=30.0, + spine=simple_line_path, + center_parameter=0.5, + wall_thickness=2.0, + connector_length=60.0, + ) + + # Should produce a valid solid + assert connector is not None + # If BREP, metadata is at index 4 + if len(connector) == 5: + assert 'brep' in connector[4] + + def test_full_segmentation_workflow(self, simple_line_path): + """End-to-end segmentation test.""" + from yapcad.manufacturing import ( + segment_swept_element, + SweptElementProvenance, + CutPoint, + ) + from yapcad.geom3d_util import sweep_adaptive + from yapcad.geom import line, point + + # Create a simple hollow beam + # Outer profile (20x30 rectangle) + hw, hh = 10, 15 + outer = [[ + line(point(-hw, -hh, 0), point(hw, -hh, 0)), + line(point(hw, -hh, 0), point(hw, hh, 0)), + line(point(hw, hh, 0), point(-hw, hh, 0)), + line(point(-hw, hh, 0), point(-hw, -hh, 0)), + ]] + + # Inner profile (hollow, 2mm wall) + wt = 2.0 + iw, ih = hw - wt, hh - wt + inner = [[ + line(point(-iw, -ih, 0), point(iw, -ih, 0)), + line(point(iw, -ih, 0), point(iw, ih, 0)), + line(point(iw, ih, 0), point(-iw, ih, 0)), + line(point(-iw, ih, 0), point(-iw, -ih, 0)), + ]] + + # Create swept beam + beam = sweep_adaptive( + outer, simple_line_path, + inner_profiles=inner, + angle_threshold_deg=5.0, + ) + + # Create provenance + prov = SweptElementProvenance( + id="test_beam", + operation="sweep_adaptive", + outer_profile=outer, + spine=simple_line_path, + inner_profile=inner, + wall_thickness=wt, + metadata={'solid': beam}, + ) + + # Define cut + cuts = [CutPoint("test_beam", 0.5)] + + # Segment + result = segment_swept_element(prov, cuts) + + # Verify results + assert result.segment_count == 2 + assert len(result.connectors) == 1 + assert "test_beam_seg_0" in result.assembly_graph + assert "test_beam_seg_1" in result.assembly_graph