"""Generic SolidWorks JSON/IR to build123d code translator.""" from __future__ import annotations import json import math import os import re from copy import deepcopy from typing import Any, Dict, Optional SW_END_CONDITIONS = { 0: "Blind", 1: "ThroughAll", 2: "ThroughAllBoth", 3: "UpToVertex", 4: "UpToSurface", 5: "OffsetFromSurface", 6: "ThroughAllAndBlind", 7: "UpToBody", 8: "MidPlane", 9: "ThroughNext", } THROUGH_CUT_AMOUNT_MM = 200 def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: """Normalize supported input formats to the backend internal IR.""" if "operations" in data and "sketches" in data: return enrich_rebuild_parameters(data) if "features" in data: return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: """Add a generic editable-parameter index without changing feature history. The returned rebuild JSON remains the source of truth for execution. The `editable_parameters` section is an index of JSON paths that a UI or caller can modify safely while preserving the original feature order and links. """ enriched = dict(data) enriched["editable_parameters"] = extract_editable_parameters(enriched) enriched["parameterization_status"] = analyze_parameterization_status(enriched) return enriched def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: issues = [] for sketch in data.get("sketches", []): host_reference = sketch.get("host_reference", {}) reference = host_reference.get("reference") or {} if reference.get("kind") == "face" and not reference.get("owner_feature"): issues.append({ "kind": "missing_stable_face_owner", "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, "message": ( "Sketch is attached to a face geometry, but the JSON does not identify " "the owning feature/face id. Parameter edits may require updating this " "sketch workplane manually unless the plugin exports stable face ownership." ), }) for op in data.get("operations", []): if op.get("type") in ("unsupported", "unknown"): sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") issues.append({ "kind": "unsupported_geometry_feature", "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, "message": ( f"SolidWorks feature '{sw_type}' is present in the history, but the " "core build123d translator has no generic implementation for it. " "The feature is retained in IR and must not be treated as a complete rebuild." ), }) if op.get("type") == "hole": host_face = op.get("parameters", {}).get("host_face") or {} if host_face and not host_face.get("frame"): issues.append({ "kind": "missing_hole_host_frame", "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "message": ( "Hole feature has a host face, but the JSON does not include the " "face-local x/y axes. The translator can infer common axis-aligned " "cases, but the plugin should export the sketch/face frame for exact " "generic hole placement." ), }) if op.get("type") == "extrude_cut": end_code = op.get("parameters", {}).get("end_condition_code") if end_code in (3, 4, 5, 7, 9): params = op.get("parameters", {}) has_termination_reference = any( params.get(key) for key in ( "end_condition_reference", "reverse_end_condition_reference", "termination_reference", ) ) kind = ( "sw_end_condition_requires_exact_translator" if has_termination_reference else "missing_extrude_termination_reference" ) issues.append({ "kind": kind, "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "end_condition_code": end_code, "end_condition": SW_END_CONDITIONS.get(end_code), "message": ( "This SW cut uses a non-blind end condition. ThroughAll can be " "replayed generically, but ThroughNext/UpTo-style rebuilds need the " "selected terminating face/body/reference from the plugin for exact 1:1." ), }) if op.get("type") in ("revolve_cut", "revolve_add"): axis_reference = op.get("parameters", {}).get("axis_reference") if not axis_reference or not ( isinstance(axis_reference, dict) and axis_reference.get("origin_mm") and axis_reference.get("direction") ): axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] if axis_candidates: issues.append({ "kind": "revolve_axis_inferred_from_candidate", "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "message": ( "Revolve feature lacks the original SolidWorks selected axis, but " "the translator can use a construction-line candidate. For exact " "auditability the plugin should still export the selected axis " "reference and selection mark." ), }) continue issues.append({ "kind": "missing_revolve_axis_reference", "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "message": ( "Revolve feature does not include the SolidWorks selected axis. " "The translator can only infer an axis from the sketch workplane, " "which is not reliable enough for exact 1:1 rebuild." ), }) if op.get("type") in ("linear_pattern", "pattern_linear"): params = op.get("parameters", {}) if not params.get("source_features") or not _linear_pattern_offsets(op): issues.append({ "kind": "linear_pattern_missing_source_or_direction", "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "message": ( "This SW linear pattern lacks source-feature selection or direction data. " "The translator can replay patterns when source features and offsets are " "available; otherwise the plugin should export the selected feature list " "and pattern direction references." ), }) if op.get("type") in ("fillet", "chamfer"): selectors = op.get("selectors") or [] if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): issues.append({ "kind": "missing_original_feature_selection", "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, "message": ( "This feature only has final-geometry edge signatures. For exact replay " "the plugin should export the original SolidWorks feature selections " "including persistent references and selection marks." ), }) return { "safe_to_edit": not issues, "issues": issues, } def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: stack = [selector] while stack: value = stack.pop() if isinstance(value, dict): if value.get("persistent_reference"): return True stack.extend(value.values()) elif isinstance(value, list): stack.extend(value) return False def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: parameters: list[Dict[str, Any]] = [] sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} for op_index, op in enumerate(data.get("operations", [])): op_type = op.get("type", "") op_name = op.get("name", op.get("id", f"operation_{op_index}")) op_path = f"/operations/{op_index}" params = op.get("parameters", {}) if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: semantic = "body_length" if op_type == "extrude_add" else "cut_depth" parameters.append(_editable_param( id=f"{op.get('id', op_index)}.distance_mm", label=f"{op_name} distance", semantic=semantic, unit="mm", value=params.get("distance_mm"), path=f"{op_path}/parameters/distance_mm", feature=op, )) if "reverse_distance_mm" in params: parameters.append(_editable_param( id=f"{op.get('id', op_index)}.reverse_distance_mm", label=f"{op_name} reverse distance", semantic="reverse_depth", unit="mm", value=params.get("reverse_distance_mm"), path=f"{op_path}/parameters/reverse_distance_mm", feature=op, )) if op_type in ("fillet", "chamfer"): key = "radius_mm" if op_type == "fillet" else "distance_mm" if key in params: parameters.append(_editable_param( id=f"{op.get('id', op_index)}.{key}", label=f"{op_name} {key}", semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", unit="mm", value=params.get(key), path=f"{op_path}/parameters/{key}", feature=op, )) sketch_id = op.get("sketch") sketch = sketches.get(sketch_id) if sketch: parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) return parameters def _extract_sketch_parameters( sketch: Dict[str, Any], sketch_id: str, op: Dict[str, Any], op_index: int, data: Dict[str, Any], ) -> list[Dict[str, Any]]: parameters: list[Dict[str, Any]] = [] sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) if sketch_index is None: return parameters op_type = op.get("type", "") entities = sketch.get("entities", []) drawable = [entity for entity in entities if not entity.get("construction", False)] for entity_index, entity in enumerate(entities): entity_type = entity.get("type") entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): center = entity.get("center", [0, 0, 0]) radius = entity.get("radius_mm") semantic = "hole" if op_type == "extrude_cut" else "circle_profile" if radius is not None: parameters.append(_editable_param( id=f"{sketch_id}.entity{entity_index}.radius_mm", label=f"{sketch.get('name', sketch_id)} circle radius", semantic=f"{semantic}_radius", unit="mm", value=radius, path=f"{entity_path}/radius_mm", feature=op, )) for axis, value in zip(("x", "y"), center[:2]): parameters.append(_editable_param( id=f"{sketch_id}.entity{entity_index}.center_{axis}", label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", semantic=f"{semantic}_center_{axis}", unit="mm", value=value, path=f"{entity_path}/center/{0 if axis == 'x' else 1}", feature=op, )) bounds = _sketch_bounds(drawable) if bounds: min_x, min_y, max_x, max_y = bounds center_x = (min_x + max_x) / 2 center_y = (min_y + max_y) / 2 width = max_x - min_x height = max_y - min_y semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" for suffix, value, semantic in ( ("center_x", center_x, f"{semantic_prefix}_center_x"), ("center_y", center_y, f"{semantic_prefix}_center_y"), ("width", width, f"{semantic_prefix}_width"), ("height", height, f"{semantic_prefix}_height"), ): parameters.append(_editable_param( id=f"{sketch_id}.{suffix}", label=f"{sketch.get('name', sketch_id)} {suffix}", semantic=semantic, unit="mm", value=value, path=f"/sketches/{sketch_index}", feature=op, editable=False, note="Derived from sketch entity bounds; edit underlying entities to change this safely.", )) workplane = sketch.get("workplane", {}) origin = workplane.get("origin_mm") if origin: for axis, value in zip(("x", "y", "z"), origin[:3]): parameters.append(_editable_param( id=f"{sketch_id}.workplane_origin_{axis}", label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", semantic=f"sketch_plane_origin_{axis}", unit="mm", value=value, path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", feature=op, )) return parameters def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: points: list[tuple[float, float]] = [] for entity in entities: for key in ("start", "end", "center"): point = entity.get(key) if point and len(point) >= 2: points.append((float(point[0]), float(point[1]))) radius = entity.get("radius_mm") center = entity.get("center") if radius is not None and center and len(center) >= 2: cx, cy = float(center[0]), float(center[1]) r = float(radius) points.extend([(cx - r, cy - r), (cx + r, cy + r)]) if not points: return None xs = [point[0] for point in points] ys = [point[1] for point in points] return min(xs), min(ys), max(xs), max(ys) def _editable_param( *, id: str, label: str, semantic: str, unit: str, value: Any, path: str, feature: Dict[str, Any], editable: bool = True, note: Optional[str] = None, ) -> Dict[str, Any]: result = { "id": id, "label": label, "semantic": semantic, "unit": unit, "value": value, "path": path, "editable": editable, "feature": { "id": feature.get("id"), "name": feature.get("name"), "type": feature.get("type"), "source_index": feature.get("source_feature", {}).get("index"), }, } if note: result["note"] = note return result def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: """Convert the current SW plugin feature dump into the backend IR.""" features = data.get("features", []) sketches = [] operations = [] last_sketch_id = None last_build_op = None references = [] source_bbox = _source_bbox_from_plugin_json(data) if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): operations.append(_convert_sw_assembly(data)) part_name = data.get("part_name", "part") return { "version": "ir-0.1", "metadata": { "source": { "format": "sw-plugin-json", "file_name": f"{part_name}.sldasm", "sw_version": data.get("sw_version"), } }, "sketches": sketches, "operations": operations, "references": references, "validation_hints": data.get("validation_hints", {}), "geometry_inventory": data.get("geometry_inventory", {}), "rebuild_contract": data.get("rebuild_contract", {}), } for index, feature in enumerate(features): if feature.get("is_suppressed"): continue feature_type = feature.get("type", "") type_name = feature.get("type_name", "") feature_id = feature.get("id") or f"feat_{index:03d}" feature_name = feature.get("name", feature_id) if feature_type in ("refplane", "refaxis"): references.append(_convert_sw_reference(feature, index)) elif feature_type == "sketch": sketch_id = f"sketch_{len(sketches):03d}" sketches.append(_convert_sw_sketch(feature, sketch_id, index)) last_sketch_id = sketch_id elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id op = _convert_sw_extrude(feature, type_name, sketch_ref, index) operations.append(op) last_build_op = op elif feature_type == "revolve": sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id op = _convert_sw_revolve(feature, type_name, sketch_ref, index) operations.append(op) last_build_op = op elif feature_type == "hole": op = _convert_sw_hole(feature, index) operations.append(op) last_build_op = op elif feature_type == "pattern_linear": data_block = feature.get("linear_pattern_data", {}) source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) source_frame = _source_pattern_frame(source_op or last_build_op, sketches) operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) elif feature_type == "pattern_mirror": data_block = feature.get("mirror_data") or {} src_features = data_block.get("source_features") or [] mirror_origin = data_block.get("mirror_plane_origin") mirror_normal = data_block.get("mirror_plane_normal") operations.append({ "id": feature_id, "name": feature_name, "type": "pattern_mirror", "parameters": {"source_features": src_features}, "raw_parameters": { "mirror_plane_origin": mirror_origin, "mirror_plane_normal": mirror_normal, }, "source_feature": _source_feature(feature, index), }) elif feature_type == "fillet": data_block = feature.get("fillet_data", {}) radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) operations.append({ "id": feature_id, "name": feature_name, "type": "fillet", "parameters": {"radius_mm": radius_mm}, "selectors": _feature_selection_selectors(feature, data_block), "selection_source": _feature_selection_source(feature, data_block), "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), }) elif feature_type == "chamfer": data_block = feature.get("chamfer_data", {}) distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) operations.append({ "id": feature_id, "name": feature_name, "type": "chamfer", "parameters": {"distance_mm": distance_mm}, "selectors": _feature_selection_selectors(feature, data_block), "selection_source": _feature_selection_source(feature, data_block), "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), }) elif _is_imported_body_feature(feature): op = _convert_sw_imported_body(feature, index) operations.append(op) last_build_op = op elif feature_type == "moveface": data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} op = { "id": feature_id, "name": feature_name, "type": "move_face", "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, "source_feature": _source_feature(feature, index), } operations.append(op) last_build_op = op elif feature_type not in _SW_METADATA_FEATURE_TYPES: operations.append({ "id": feature_id, "name": feature_name, "type": "unsupported", "parameters": {"sw_type": type_name or feature_type}, "source_feature": _source_feature(feature, index), }) part_name = data.get("part_name", "part") return { "version": "ir-0.1", "metadata": { "source": { "format": "sw-plugin-json", "file_name": f"{part_name}.sldprt", "sw_version": data.get("sw_version"), } }, "sketches": sketches, "operations": operations, "references": references, "validation_hints": data.get("validation_hints", {}), "geometry_inventory": data.get("geometry_inventory", {}), "rebuild_contract": data.get("rebuild_contract", {}), } def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: feature_type = str(feature.get("type") or "").lower() type_name = str(feature.get("type_name") or "").lower() return bool(feature.get("imported_body_data")) or feature_type in { "mbimport", "savedextbody", "importedbody", "imported", "stock", } or type_name in {"mbimport", "savedextbody", "importedbody"} def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} solid_bodies = data_block.get("solid_bodies") or [] solid_body_stats = data_block.get("solid_body_stats") or [] source_name = feature.get("name") parameters = { "sw_type": feature.get("type_name") or feature.get("type"), "source_name": source_name, "history_status": data_block.get("history_status"), "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), "solid_body_stats": solid_body_stats, "solid_bodies": solid_bodies, } return { "id": feature.get("id") or f"feat_{index:03d}", "name": feature.get("name") or f"imported_body_{index:03d}", "type": "imported_body", "parameters": parameters, "source_feature": _source_feature(feature, index), "source_imported_body": data_block, } def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: assembly_data = data.get("assembly_data") or {} components = [] for index, component in enumerate(assembly_data.get("components") or []): if component.get("is_suppressed") or component.get("is_hidden"): continue path = component.get("path") or "" component_name = component.get("name") or f"component_{index:03d}" base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name components.append({ "index": index, "name": component_name, "component_id": base_name, "source_path": path, "component_json": f"{base_name}.solidworks_rebuild_extract.json", "transform": component.get("transform") or {}, }) return { "id": "assembly_000", "name": data.get("part_name") or "assembly", "type": "assembly_compose", "parameters": { "components": components, }, "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, } def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: """Promote feature-owned SW sketches into the rebuild sketch table.""" source_sketches = [] for block_name in ("extrude_data", "revolve_data"): block = feature.get(block_name) if isinstance(block, dict): source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) if not source_sketches: return None last_id = None for sketch_data in source_sketches: sketch_id = f"sketch_{len(sketches):03d}" sketch_feature = dict(feature) sketch_feature["sketch_data"] = sketch_data if sketch_data.get("name"): sketch_feature["name"] = sketch_data.get("name") sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) last_id = sketch_id return last_id def get_part_name(data: Dict[str, Any]) -> str: part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") part_name = str(part_name) for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): if part_name.lower().endswith(suffix): part_name = part_name[:-len(suffix)] break return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: """Generate build123d Python code from generic SW/build123d IR.""" rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} if rebuild_contract and rebuild_contract.get("ready") is False: blockers = rebuild_contract.get("blockers") or [] raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") source_volume_mm3 = None source_area_mm2 = None mass_props = data.get("validation_hints", {}).get("mass_properties_raw") if mass_props and len(mass_props) >= 5: source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 source_area_mm2 = float(mass_props[4]) * 1_000_000 lines = [ "from build123d import *", "import math", f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", f"SOURCE_AREA_MM2 = {source_area_mm2!r}", "", "def _dist(a, b):", " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", "", "def _owned_face_match_score(shape, expected_faces):", " if not expected_faces:", " return 0.0", " try:", " available = list(shape.faces())", " except Exception:", " return 1e99", " total = 0.0", " for expected in expected_faces:", " bbox_m = expected.get('box_m')", " if not bbox_m or len(bbox_m) < 6 or not available:", " total += 1e6", " continue", " target_box = [float(v) * 1000 for v in bbox_m[:6]]", " surface = expected.get('surface') or {}", " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", " target_area = float(expected.get('area_m2') or 0) * 1_000_000", " ranked = []", " for index, face in enumerate(available):", " try:", " fb = face.bounding_box()", " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", " geom_name = getattr(geom, 'name', str(geom)).lower()", " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", " except Exception:", " continue", " if not ranked:", " total += 1e6", " continue", " best, index = min(ranked, key=lambda item: item[0])", " total += best", " available.pop(index)", " return total / max(len(expected_faces), 1)", "", "def _candidate_score(shape, expected_faces=None):", " # Owned faces describe this exact SW history step. Final-part mass properties", " # must not be used to choose an intermediate feature candidate.", " if expected_faces:", " return _owned_face_match_score(shape, expected_faces)", " score = 0", " if SOURCE_VOLUME_MM3 is not None:", " try:", " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", " except Exception:", " score += 1e99", " if SOURCE_AREA_MM2 is not None:", " try:", " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", " except Exception:", " score += 1e99", " score += _owned_face_match_score(shape, expected_faces)", " return score", "", "def _edge_endpoints(edge):", " vertices = [v.to_tuple() for v in edge.vertices()]", " if len(vertices) != 2:", " center = edge.center().to_tuple()", " return center, center", " return vertices[0], vertices[1]", "", "def _edge_match_score(edge, start, end):", " a, b = _edge_endpoints(edge)", " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", " containment_score = edge.distance_to(start) + edge.distance_to(end)", " return min(endpoint_score, containment_score)", "", "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", " edges = list(part.edges())", " selected = []", " used = set()", " for selector in selector_points:", " start, end = selector", " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " score, index, edge = ranked[0]", " if score > tolerance:", " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", " if index not in used:", " selected.append(edge)", " used.add(index)", " return selected", "", "def _bbox_match_score(edge, bbox_mm):", " if not bbox_mm or len(bbox_mm) < 6:", " return float('inf')", " try:", " a, b = _edge_endpoints(edge)", " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", " mins = tuple(float(bbox_mm[i]) for i in range(3))", " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", " pad = max(0.25, diag * 0.15)", " def point_score(point):", " total = 0.0", " for axis in range(3):", " if point[axis] < mins[axis] - pad:", " total += mins[axis] - pad - point[axis]", " elif point[axis] > maxs[axis] + pad:", " total += point[axis] - maxs[axis] - pad", " return total", " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", " except Exception:", " return float('inf')", "", "def _circle_match_score(edge, circle_params):", " if not circle_params or len(circle_params) < 7:", " return float('inf')", " try:", " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", " geom_name = getattr(geom_type, 'name', str(geom_type))", " if 'CIRCLE' not in geom_name:", " return float('inf')", " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", " target_radius = float(circle_params[6]) * 1000", " edge_center = edge.arc_center.to_tuple()", " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", " except Exception:", " return float('inf')", "", "def _line_match_score(edge, line_params):", " if not line_params or len(line_params) < 6:", " return float('inf')", " try:", " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", " geom_name = getattr(geom_type, 'name', str(geom_type))", " if 'LINE' not in geom_name:", " return float('inf')", " target_point = tuple(float(v) * 1000 for v in line_params[:3])", " target_dir = tuple(float(v) for v in line_params[3:6])", " a, b = _edge_endpoints(edge)", " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", " length = math.sqrt(sum(v * v for v in edge_dir_raw))", " if length <= 0:", " return float('inf')", " edge_dir = tuple(v / length for v in edge_dir_raw)", " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", " distance = edge.distance_to(target_point)", " return distance + parallel * 10", " except Exception:", " return float('inf')", "", "def select_edges_by_selectors(part, selectors, tolerance=0.5):", " if part is None:", " return []", " edges = list(part.edges())", " selected = []", " used = set()", " for selector in selectors or []:", " geometry = selector.get('geometry', {})", " start_vertex = geometry.get('start_vertex')", " end_vertex = geometry.get('end_vertex')", " start = start_vertex.get('point_m') if start_vertex else None", " end = end_vertex.get('point_m') if end_vertex else None", " bbox_mm = geometry.get('bbox_mm')", " if start and end:", " start_mm = tuple(float(v) * 1000 for v in start)", " end_mm = tuple(float(v) * 1000 for v in end)", " line_params = geometry.get('curve', {}).get('line_params')", " if line_params:", " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " else:", " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " else:", " line_params = geometry.get('curve', {}).get('line_params')", " circle_params = geometry.get('curve', {}).get('circle_params')", " if line_params:", " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " elif bbox_mm:", " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " else:", " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", " score, index, edge = ranked[0]", " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", " if score > selector_tolerance:", " # Skip edges that don't match well enough", " continue", " if index not in used:", " selected.append(edge)", " used.add(index)", " return selected", "", "def _point_inside_bbox(point, bbox_mm, pad=0.25):", " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", "", "def fillet_edges_from_owned_surface_bbox(part, selectors):", " if part is None:", " return []", " boxes = []", " seen_boxes = set()", " for selector in selectors or []:", " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", " continue", " bbox = (selector.get('geometry') or {}).get('bbox_mm')", " if bbox and len(bbox) >= 6:", " normalized = [float(v) for v in bbox[:6]]", " key = tuple(round(v, 6) for v in normalized)", " if key not in seen_boxes:", " seen_boxes.add(key)", " boxes.append(normalized)", " if len(boxes) < 2:", " return []", " selected = []", " used_keys = set()", " for box in boxes:", " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", " pad = max(0.25, diag * 0.08)", " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", " circle_candidates = []", " if thin_axes:", " thin_axis = thin_axes[0]", " for edge in part.edges():", " try:", " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", " geom_name = getattr(geom_type, 'name', str(geom_type))", " if 'CIRCLE' not in geom_name:", " continue", " eb = edge.bounding_box()", " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", " ok = True", " score = 0.0", " for axis in range(3):", " if axis == thin_axis:", " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", " if plane_delta > pad:", " ok = False", " break", " score += plane_delta", " else:", " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", " ok = False", " break", " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", " if not ok:", " continue", " key = tuple(round(v, 5) for v in edge_box)", " circle_candidates.append((score, key, edge))", " except Exception:", " continue", " if circle_candidates:", " circle_candidates.sort(key=lambda item: item[0])", " for _, key, edge in circle_candidates:", " if key in used_keys:", " continue", " used_keys.add(key)", " selected.append(edge)", " break", " continue", " box_candidates = []", " for edge in part.edges():", " try:", " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", " geom_name = getattr(geom_type, 'name', str(geom_type))", " if 'LINE' not in geom_name:", " continue", " a, b = _edge_endpoints(edge)", " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", " continue", " key = tuple(round(v, 5) for point in (a, b) for v in point)", " box_candidates.append((float(edge.length), key, edge))", " except Exception:", " continue", " if not box_candidates:", " continue", " box_candidates.sort(key=lambda item: item[0], reverse=True)", " for _, key, edge in box_candidates:", " reverse_key = key[3:] + key[:3]", " if key in used_keys or reverse_key in used_keys:", " continue", " used_keys.add(key)", " selected.append(edge)", " break", " if selected:", " return selected", " union_bbox = [", " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", " for i in range(6)", " ]", " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", " pad = max(0.25, diag * 0.05)", " candidates = []", " for edge in part.edges():", " try:", " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", " geom_name = getattr(geom_type, 'name', str(geom_type))", " if 'LINE' not in geom_name:", " continue", " a, b = _edge_endpoints(edge)", " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", " continue", " candidates.append((float(edge.length), edge))", " except Exception:", " continue", " if not candidates:", " return []", " candidates.sort(key=lambda item: item[0], reverse=True)", " return [candidates[0][1]]", "", "def fillet_with_tolerance(edges, radius):", " radii = [float(radius)]", " shrink = max(0.001, abs(float(radius)) * 0.001)", " if float(radius) > shrink:", " radii.append(float(radius) - shrink)", " radii.append(float(radius) * 0.99)", " last_error = None", " for candidate_radius in radii:", " if candidate_radius <= 0:", " continue", " try:", " return fillet(edges, radius=candidate_radius)", " except Exception as exc:", " last_error = exc", " continue", " if last_error:", " raise last_error", " return fillet(edges, radius=radius)", "", "def fillet_selected(part, radius, selectors, owned_faces=None):", " if part is None:", " return part", " if not selectors:", " # No edge selectors - skip fillet to avoid failing on all edges", " return part", " candidates = []", " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", " if owned_edges:", " try:", " candidates.append(fillet_with_tolerance(owned_edges, radius))", " except Exception:", " pass", " try:", " target_edges = select_edges_by_selectors(part, selectors)", " if target_edges:", " candidates.append(fillet_with_tolerance(target_edges, radius))", " except Exception:", " pass", " result = part", " applied_any = False", " for selector in selectors:", " edges = select_edges_by_selectors(result, [selector])", " if not edges:", " continue # Skip selectors that don't match any edge", " try:", " result = fillet_with_tolerance([edges[0]], radius)", " applied_any = True", " except Exception:", " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", " continue", " if applied_any:", " candidates.append(result)", " variants = []", " for selector in selectors:", " edges = select_edges_by_selectors(part, [selector])", " if not edges:", " continue", " try:", " variants.append(fillet_with_tolerance([edges[0]], radius))", " except Exception:", " continue", " if variants:", " try:", " union_result = part", " for variant in variants:", " union_result = union_result + variant", " candidates.append(union_result)", " except Exception:", " pass", " try:", " intersection_result = part", " for variant in variants:", " intersection_result = intersection_result & variant", " candidates.append(intersection_result)", " except Exception:", " pass", " if candidates:", " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", " return part", "", "def chamfer_selected(part, distance, selectors, owned_faces=None):", " if part is None:", " return part", " if not selectors:", " # No edge selectors available - chamfer would fail on all edges", " return part", " candidates = []", " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", " if owned_edges:", " try:", " candidates.append(chamfer(owned_edges, length=distance))", " except Exception:", " pass", " target_edges = select_edges_by_selectors(part, selectors)", " if target_edges:", " try:", " candidates.append(chamfer(target_edges, length=distance))", " except Exception:", " pass", " result = part", " applied_any = False", " for selector in selectors:", " edges = select_edges_by_selectors(result, [selector])", " if not edges:", " continue", " try:", " result = chamfer([edges[0]], length=distance)", " applied_any = True", " except Exception:", " continue", " if applied_any:", " candidates.append(result)", " if candidates:", " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", " return part", "", "def is_internal_cone_face(face, part):", " try:", " bbox_m = face.get('box_m')", " surface = face.get('surface') or {}", " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", " return False", " params = surface.get('cone_params')", " if not params or len(params) < 6:", " return False", " direction = tuple(float(v) for v in params[3:6])", " axis = max(range(3), key=lambda i: abs(direction[i]))", " radial_axes = tuple(i for i in range(3) if i != axis)", " part_bbox = part.bounding_box()", " part_min = part_bbox.min.to_tuple()", " part_max = part_bbox.max.to_tuple()", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " tol = 0.25", " touches_outer = any(", " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", " for i in radial_axes", " )", " return not touches_outer", " except Exception:", " return False", "", "def is_external_cone_face(face, part):", " try:", " bbox_m = face.get('box_m')", " surface = face.get('surface') or {}", " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", " return False", " params = surface.get('cone_params')", " if not params or len(params) < 6:", " return False", " direction = tuple(float(v) for v in params[3:6])", " axis = max(range(3), key=lambda i: abs(direction[i]))", " radial_axes = tuple(i for i in range(3) if i != axis)", " part_bbox = part.bounding_box()", " part_min = part_bbox.min.to_tuple()", " part_max = part_bbox.max.to_tuple()", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " tol = 0.25", " return any(", " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", " for i in radial_axes", " )", " except Exception:", " return False", "", "def make_owned_external_cone_chamfer_cutter(face):", " surface = face.get('surface') or {}", " params = surface.get('cone_params')", " bbox_m = face.get('box_m')", " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", " return None", " origin = tuple(float(v) * 1000 for v in params[:3])", " direction = tuple(float(v) for v in params[3:6])", " norm = math.sqrt(sum(v * v for v in direction))", " base_radius = abs(float(params[6]) * 1000)", " half_angle = abs(float(params[7]))", " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", " return None", " direction = tuple(v / norm for v in direction)", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " projections = []", " for x in (mins[0], maxs[0]):", " for y in (mins[1], maxs[1]):", " for z in (mins[2], maxs[2]):", " delta = (x - origin[0], y - origin[1], z - origin[2])", " axial = sum(delta[i] * direction[i] for i in range(3))", " projections.append(axial)", " start = min(projections)", " end = max(projections)", " height = max(0.001, end - start)", " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", " outer_radius = max(r1, r2) + 0.001", " center_offset = (start + end) / 2", " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", " if r1 <= 1e-9:", " r1 = 1e-6", " if r2 <= 1e-9:", " r2 = 1e-6", " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", " return cutter_part.part", "", "def chamfer_owned_external_cones(part, faces):", " if part is None:", " return part, False", " result = part", " applied = False", " for face in faces or []:", " if not is_external_cone_face(face, result):", " continue", " cutter = make_owned_external_cone_chamfer_cutter(face)", " new_result = safe_subtract(result, cutter)", " if new_result is not result:", " result = new_result", " applied = True", " return result, applied", "", "def chamfer_owned_internal_cones(part, faces):", " if part is None:", " return part, False", " result = part", " applied = False", " for face in faces or []:", " if not is_internal_cone_face(face, result):", " continue", " cutter = make_owned_cone_cutter(face)", " new_result = safe_subtract(result, cutter)", " if new_result is not result:", " result = new_result", " applied = True", " return result, applied", "", "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", " result, applied = chamfer_owned_internal_cones(part, cone_faces)", " if applied:", " return result", " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", " result, applied = chamfer_owned_external_cones(part, cone_faces)", " if applied:", " return result", " return chamfer_selected(part, distance, selectors, owned_faces)", "", "def safe_subtract(part, cutter):", " if part is None or cutter is None:", " return part", " try:", " vol_before = float(part.volume)", " except Exception:", " vol_before = -1", " try:", " cut = part - cutter", " if cut is None:", " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", " return part", " # Accept the cut even when solids() reports 0 – can happen", " # for valid boolean results with non-standard structures.", " try:", " nb_solids = len(list(cut.solids()))", " if nb_solids == 0:", " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", " except Exception:", " pass", " return cut", " except Exception as e:", " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", " return part", "", "def _project_bbox_along_direction(bbox, origin, direction):", " mins = tuple(float(bbox[i]) for i in range(3))", " maxs = tuple(float(bbox[i + 3]) for i in range(3))", " projections = []", " for x in (mins[0], maxs[0]):", " for y in (mins[1], maxs[1]):", " for z in (mins[2], maxs[2]):", " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", " return min(projections), max(projections)", "", "def make_owned_cylinder_cutter(face, target_part=None):", " surface = face.get('surface') or {}", " params = surface.get('cylinder_params')", " bbox_m = face.get('box_m')", " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", " return None", " origin = tuple(float(v) * 1000 for v in params[:3])", " direction = tuple(float(v) for v in params[3:6])", " norm = math.sqrt(sum(v * v for v in direction))", " radius = abs(float(params[6]) * 1000)", " if norm <= 1e-9 or radius <= 1e-9:", " return None", " direction = tuple(v / norm for v in direction)", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", " if target_part is not None:", " try:", " part_bbox = target_part.bounding_box()", " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", " through_tolerance = max(1.0, radius * 0.12)", " if abs(start - part_start) <= through_tolerance:", " start = part_start", " if abs(end - part_end) <= through_tolerance:", " end = part_end", " except Exception:", " pass", " height = max(0.001, end - start)", " center_offset = (start + end) / 2", " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", " return cutter_part.part", "", "def make_owned_cone_cutter(face):", " surface = face.get('surface') or {}", " params = surface.get('cone_params')", " bbox_m = face.get('box_m')", " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", " return None", " origin = tuple(float(v) * 1000 for v in params[:3])", " direction = tuple(float(v) for v in params[3:6])", " norm = math.sqrt(sum(v * v for v in direction))", " base_radius = abs(float(params[6]) * 1000)", " half_angle = abs(float(params[7]))", " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", " return None", " direction = tuple(v / norm for v in direction)", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " projections = []", " for x in (mins[0], maxs[0]):", " for y in (mins[1], maxs[1]):", " for z in (mins[2], maxs[2]):", " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", " start = min(projections)", " end = max(projections)", " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", " height = max(0.001, end - start) + 0.001", " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", " # direction the radius tapers rather than expands for hole drill tips.", " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", " if max(r1, r2) <= 1e-9:", " return None", " if r1 <= 1e-9:", " r1 = 1e-6", " if r2 <= 1e-9:", " r2 = 1e-6", " center_offset = (start + end) / 2", " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", " return cutter_part.part", "", "def make_owned_face_cutter(face, target_part=None):", " surface = face.get('surface') or {}", " if surface.get('is_cylinder'):", " return make_owned_cylinder_cutter(face, target_part)", " if surface.get('is_cone'):", " return make_owned_cone_cutter(face)", " return None", "", "def cut_owned_cylindrical_faces(part, faces):", " result = part", " for face in faces or []:", " cutter = make_owned_face_cutter(face, result)", " result = safe_subtract(result, cutter)", " return result", "", "def make_owned_flip_side_ring_cutter(face, target_part):", " surface = face.get('surface') or {}", " params = surface.get('cylinder_params')", " bbox_m = face.get('box_m')", " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", " return None", " origin = tuple(float(v) * 1000 for v in params[:3])", " direction = tuple(float(v) for v in params[3:6])", " norm = math.sqrt(sum(v * v for v in direction))", " inner_radius = abs(float(params[6]) * 1000)", " if norm <= 1e-9 or inner_radius <= 1e-9:", " return None", " direction = tuple(v / norm for v in direction)", " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", " height = max(0.001, end - start)", " center_offset = (start + end) / 2", " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", " try:", " part_bbox = target_part.bounding_box()", " part_min = part_bbox.min.to_tuple()", " part_max = part_bbox.max.to_tuple()", " radial = []", " for x in (part_min[0], part_max[0]):", " for y in (part_min[1], part_max[1]):", " for z in (part_min[2], part_max[2]):", " delta = (x - origin[0], y - origin[1], z - origin[2])", " axial = sum(delta[i] * direction[i] for i in range(3))", " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", " radial.append(math.sqrt(sum(v * v for v in perp)))", " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", " except Exception:", " outer_radius = inner_radius + 100.0", " if outer_radius <= inner_radius + 1e-6:", " return None", " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", " return cutter_part.part", "", "def cut_owned_flip_side_cylindrical_faces(part, faces):", " result = part", " for face in faces or []:", " cutter = make_owned_flip_side_ring_cutter(face, result)", " result = safe_subtract(result, cutter)", " return result", "", "def cut_owned_bbox(part, bbox_mm):", " if part is None or not bbox_mm or len(bbox_mm) < 6:", " return part", " mins = tuple(float(bbox_mm[i]) for i in range(3))", " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", " cutter = Pos(center) * Box(size[0], size[1], size[2])", " return safe_subtract(part, cutter)", "", "def shape_face_count(shape):", " if shape is None:", " return 0", " try:", " return len(list(shape.faces()))", " except Exception:", " return 0", "", "def safe_union(part, solid, preserve_visible=False):", " if part is None:", " return solid", " if solid is None:", " return part", " try:", " fused = part + solid", " # OCCT fuse succeeded; always return the fused result.", " # is_valid() can return False for edge cases where the geometry", " # is actually correct (e.g. touching-at-faces). Accept it.", " return fused", " except Exception as e:", " print(f' UNION: fuse threw {type(e).__name__}: {e}')", " pass", " try:", " compound = Compound.make_composite([part, solid])", " fused = compound.fuse()", " try:", " if len(list(fused.solids())) > 0:", " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", " return fused", " except Exception:", " pass", " except Exception as e:", " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", " pass", " shapes = []", " try:", " shapes.extend(list(part.solids()))", " except Exception:", " shapes.append(part)", " try:", " shapes.extend(list(solid.solids()))", " except Exception:", " shapes.append(solid)", " return Compound.make_composite(shapes)", "", "def sw_inverted_profile_cut(part, profile_solid, normal):", " if part is None or profile_solid is None:", " return part", " try:", " part_bbox = part.bounding_box()", " profile_bbox = profile_solid.bounding_box()", " n = tuple(float(v) for v in normal)", " axis = max(range(3), key=lambda i: abs(n[i]))", " part_min = part_bbox.min.to_tuple()", " part_max = part_bbox.max.to_tuple()", " prof_min = profile_bbox.min.to_tuple()", " prof_max = profile_bbox.max.to_tuple()", " margin = 5.0", " mins = [part_min[i] - margin for i in range(3)]", " maxs = [part_max[i] + margin for i in range(3)]", " mins[axis] = prof_min[axis] - margin * 0.05", " maxs[axis] = prof_max[axis] + margin * 0.05", " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", " envelope = Pos(center) * Box(size[0], size[1], size[2])", " outside_profile = safe_subtract(envelope, profile_solid)", " return safe_subtract(part, outside_profile)", " except Exception:", " return part", "", "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", " part = sw_inverted_profile_cut(part, profile_solid, normal)", " if part is None or profile_solid is None:", " return part", " try:", " outer_radius = abs(float(outer_radius_mm))", " inner_radius = abs(float(inner_radius_mm))", " except Exception:", " return part", " if outer_radius <= inner_radius + 1e-6:", " return part", " try:", " profile_bbox = profile_solid.bounding_box()", " prof_min = profile_bbox.min.to_tuple()", " prof_max = profile_bbox.max.to_tuple()", " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", " n = tuple(float(v) for v in normal)", " axis = max(range(3), key=lambda i: abs(n[i]))", " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", " margin_xy = max(2.0, span_xy * 0.05)", " margin_z = 0.1", " size = tuple(", " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", " for i in range(3)", " )", " plane = Plane(", " origin=center,", " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", " z_dir=n,", " )", " cut_extent = prof_max[axis] - prof_min[axis]", " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", " with BuildSketch(plane) as ring_sketch:", " Circle(outer_radius)", " Circle(inner_radius, mode=Mode.SUBTRACT)", " ring = extrude(ring_sketch.sketch, amount=cut_amount)", " return safe_union(part, ring)", " except Exception:", " return part", "", "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", " if part is None:", " return part", " if not positions or diameter <= 0 or depth <= 0:", " return part", " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", " frame = host_face.get('frame') or {}", " normal = tuple(float(v) for v in plane[:3])", " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", " bbox = part.bounding_box()", " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", " dot = sum(toward_center[i] * normal[i] for i in range(3))", " inward = normal if dot >= 0 else tuple(-v for v in normal)", " axis = max(range(3), key=lambda i: abs(inward[i]))", " rotation = (0, 0, 0)", " if axis == 0:", " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", " elif axis == 1:", " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", " elif inward[2] < 0:", " rotation = (180, 0, 0)", " tip_depth = 0", " if include_drill_tip and drill_angle > 0:", " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", " countersink_depth = 0", " if countersink_diameter > diameter and countersink_angle > 0:", " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", " result = part", " for pos in positions:", " x, y = float(pos[0]), float(pos[1])", " if has_frame:", " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", " elif axis == 0:", " start = (plane_point[0], x, y)", " elif axis == 1:", " start = (x, plane_point[1], -y)", " else:", " start = (x, y, plane_point[2])", " cut_depth = depth", " if depth >= 199:", " part_min = bbox.min.to_tuple()", " part_max = bbox.max.to_tuple()", " corners = []", " for ci in range(2):", " for cj in range(2):", " for ck in range(2):", " corners.append((", " part_min[0] if ci else part_max[0],", " part_min[1] if cj else part_max[1],", " part_min[2] if ck else part_max[2],", " ))", " cut_depth = max(", " sum((corner[i] - start[i]) * inward[i] for i in range(3))", " for corner in corners", " ) + 2.0", " cutters = []", " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", " cs_depth = countersink_depth if countersink_depth > 0 else 0", " hole_start = cs_depth", " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", " if hole_depth > 0:", " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", " if cb_depth > 0:", " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", " if cs_depth > 0:", " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", " cutters.append(cs)", " if tip_depth > 0:", " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", " cutters.append(tip)", " if len(cutters) == 1:", " cutter = cutters[0]", " else:", " cutter = Compound.make_composite(cutters)", " result = safe_subtract(result, cutter)", " return result", "", ] part_name_clean = get_part_name(data) lines.append(f"def build_{part_name_clean}():") lines.append(' """Auto-generated build123d code from SolidWorks IR."""') lines.append("") sketches = {s["id"]: s for s in data.get("sketches", [])} operations = data.get("operations", []) references = {r["id"]: r for r in data.get("references", [])} generated_sketches = set() lines.append(" result = None") lines.append("") for op in sort_operations_for_history(operations): op_type = op.get("type", "") op_name = op.get("name", "") if op_type in ["unsupported", "unknown"]: lines.append(f" # Skipping unsupported metadata feature: {op_name}") lines.append("") continue if op_type == "imported_body": lines.extend(_generate_imported_body_pending(op)) elif op_type == "assembly_compose": lines.extend(_generate_assembly_compose(op)) elif op_type == "move_face": lines.extend(_generate_move_face(op)) elif op_type == "fillet": lines.extend(_generate_fillet(op)) elif op_type == "chamfer": lines.extend(_generate_chamfer(op)) elif op_type == "hole": lines.extend(_generate_hole(op)) elif op_type in ("extrude_cut", "extrude_add"): build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) sketch_id = op.get("sketch") if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") continue if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) generated_sketches.add(sketch_id) lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) elif op_type in ("revolve_cut", "revolve_add"): sketch_id = op.get("sketch") if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") continue if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: lines.extend(_generate_sketch(sketches[sketch_id], references, op)) generated_sketches.add(sketch_id) lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) elif op_type in ("linear_pattern", "pattern_linear"): lines.extend(_generate_linear_pattern(op, operations, sketches, references)) elif op_type == "pattern_mirror": lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) else: lines.append(f" # TODO: {op_type} - {op_name}") lines.append("") lines.append(" if result is None:") lines.append(' raise Exception("No solid was created")') lines.append("") lines.append(" # Clean up small inaccuracies from Boolean operations") lines.append(" try:") lines.append(" result = result.clean()") lines.append(" except Exception:") lines.append(" pass") lines.append(f' export_step(result, "{part_name_clean}.step")') lines.append(" return result") lines.append("") lines.append("# Run the function") lines.append('if __name__ == "__main__":') lines.append(f" build_{part_name_clean}()") return "\n".join(lines) def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: return [ f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", " raise NotImplementedError(", " 'Pure-JSON imported-body reconstruction is not implemented yet; '", " 'the plugin captured solid_bodies topology and the part is marked not ready.'", " )", ] def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: params = op.get("parameters") or {} components = params.get("components") or [] component_ids = [component.get("component_id") for component in components] message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" return [ f" # Pure-JSON assembly composition: {op.get('name', '')}", " raise NotImplementedError(", f" {message!r}", " )", ] def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: if not isinstance(array_data, list) or len(array_data) < 13: raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") values = [float(value or 0) for value in array_data] scale = values[12] if abs(scale) <= 1e-12: raise ValueError(f"Assembly component {component_name} has an invalid zero scale") # SOLIDWORKS stores row-vector axes and translation in elements 9..11. # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. return [ [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], [0.0, 0.0, 0.0, 1.0], ] def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: """Return operations in SW rebuild order.""" if _looks_like_reverse_history(operations): return list(reversed(operations)) if all(op.get("source_feature", {}).get("index") is not None for op in operations): return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) return sorted(operations, key=_operation_priority) def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: build_ops = [ op for op in operations if op.get("type") not in ("unsupported", "unknown") ] if len(build_ops) < 2: return False additive = {"extrude_add", "revolve_add", "sweep", "loft"} downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive def _operation_priority(op: Dict[str, Any]) -> int: op_type = op.get("type", "") if op_type == "extrude_add": return 0 if op_type in ("extrude_cut", "revolve_cut"): return 1 if op_type == "revolve_add": return 2 if op_type in ("fillet", "chamfer"): return 3 if op_type in ("sweep", "loft"): return 4 return 99 def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: for entity in sketch.get("entities", []) or []: if entity.get("construction"): continue if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: return True if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: return True valid_lines = 0 for entity in sketch.get("entities", []) or []: if entity.get("construction") or entity.get("type") != "line": continue start = entity.get("start") or [0, 0] end = entity.get("end") or [0, 0] if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: valid_lines += 1 return valid_lines >= 2 def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: if not isinstance(point, list) or len(point) < 2: return None return (round(float(point[0]), places), round(float(point[1]), places)) def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: """Reverse a sketch segment while preserving its geometric traversal.""" reversed_ent = dict(ent) reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") reversed_ent["reversed"] = not bool(ent.get("reversed", False)) if ent.get("type") == "arc": raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} axis = ent.get("curve_axis") or raw.get("curve_axis") if isinstance(axis, list) and len(axis) >= 3: # The arc's endpoints and orientation are a pair. Keep the # source `raw` untouched, but provide a flipped top-level axis for # code generation so a reversed minor arc remains a minor arc. reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 reversed_ent.pop("start_angle_deg", None) reversed_ent.pop("end_angle_deg", None) reversed_ent.pop("arc_sweep_deg", None) return reversed_ent def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: """Order sketch line/arc entities into connected loops when SW did not export contours.""" drawable = [ ent for ent in entities if ent.get("type") in ("line", "arc") and _point_key(ent.get("start")) is not None and _point_key(ent.get("end")) is not None ] if len(drawable) < 3: return entities by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} for idx, ent in enumerate(drawable): by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) if not by_node or any(len(touches) != 2 for touches in by_node.values()): return entities remaining = set(range(len(drawable))) ordered: list[Dict[str, Any]] = [] while remaining: first_idx = min(remaining) remaining.remove(first_idx) first = drawable[first_idx] loop = [first] loop_start = _point_key(first.get("start")) cursor = _point_key(first.get("end")) while cursor != loop_start: next_idx = None next_side = None for candidate_idx, side in by_node.get(cursor, []): if candidate_idx in remaining: next_idx = candidate_idx next_side = side break if next_idx is None: return entities remaining.remove(next_idx) next_ent = drawable[next_idx] if next_side == "end": next_ent = _reverse_curve_entity(next_ent) loop.append(next_ent) cursor = _point_key(next_ent.get("end")) ordered.extend(loop) return ordered def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: drawable = [ (idx, ent) for idx, ent in enumerate(entities) if not ent.get("construction", False) and ent.get("type") in ("line", "arc") and _point_key(ent.get("start")) is not None and _point_key(ent.get("end")) is not None ] if len(drawable) < 3: return [] by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} for local_idx, (_, ent) in enumerate(drawable): by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) remaining = set(range(len(drawable))) loops: list[Dict[str, Any]] = [] while remaining: first_idx = min(remaining) remaining.remove(first_idx) _, first = drawable[first_idx] loop_indices = [first_idx] loop_start = _point_key(first.get("start")) cursor = _point_key(first.get("end")) while cursor != loop_start: matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] if not matches: loop_indices = [] break next_idx, next_side = matches[0] remaining.remove(next_idx) _, next_ent = drawable[next_idx] loop_indices.append(next_idx) cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) if not loop_indices: continue entity_indices = [drawable[idx][0] for idx in loop_indices] bbox = _loop_bbox([entities[idx] for idx in entity_indices]) loops.append({ "entity_indices": entity_indices, "is_closed": True, "bbox_mm": bbox, "bbox_area_mm2": _bbox_area_2d(bbox), "source": "inferred_connected_loop", }) return loops def _loop_bbox(entities: list[Dict[str, Any]]) -> Optional[list[float]]: points = [] for ent in entities: if not isinstance(ent, dict): continue if ent.get("type") == "circle": center = ent.get("center") radius = ent.get("radius_mm") if isinstance(center, list) and len(center) >= 2 and radius is not None: radius_value = abs(float(radius)) points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) continue for key in ("start", "end", "center"): point = ent.get(key) if isinstance(point, list) and len(point) >= 2: points.append(point) if not points: return None return [ min(float(point[0]) for point in points), min(float(point[1]) for point in points), max(float(point[0]) for point in points), max(float(point[1]) for point in points), ] def _bbox_area_2d(bbox: Optional[list[float]]) -> float: if not isinstance(bbox, list) or len(bbox) < 4: return 0.0 return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: return False return ( float(outer[0]) <= float(inner[0]) + tolerance and float(outer[1]) <= float(inner[1]) + tolerance and float(outer[2]) >= float(inner[2]) - tolerance and float(outer[3]) >= float(inner[3]) - tolerance ) def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: return 0.0 ix0 = max(float(a[0]), float(b[0])) iy0 = max(float(a[1]), float(b[1])) ix1 = min(float(a[2]), float(b[2])) iy1 = min(float(a[3]), float(b[3])) intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) if smaller <= 1e-9: return 0.0 return intersection / smaller def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: radii: list[float] = [] for idx in loop.get("entity_indices", []) or []: if not isinstance(idx, int) or idx < 0 or idx >= len(entities): continue ent = entities[idx] radius = ent.get("radius_mm") if radius is not None: radii.append(abs(float(radius))) bbox = loop.get("bbox_mm") if isinstance(bbox, list) and len(bbox) >= 4: radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: if not isinstance(operation, dict): return [] radii: list[float] = [] loop_radii: list[float] = [] entities = sketch.get("entities") if isinstance(sketch, dict) else [] sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] for loop in sketch_loops: loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) def _matches_sketch_radius(value: float) -> bool: return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) for face in operation.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} params = surface.get("cylinder_params") if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: radii.append(abs(float(params[6]) * 1000)) continue box = face.get("box_m") area = face.get("area_m2") if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] non_zero_sizes = [size for size in sizes if size > 1e-4] if len(non_zero_sizes) >= 2: outer_radius = max(non_zero_sizes) / 2 area_mm2 = abs(float(area)) * 1_000_000 inner_sq = outer_radius * outer_radius - area_mm2 / math.pi inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 if _matches_sketch_radius(outer_radius): radii.append(outer_radius) if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): radii.append(inner_radius) unique: list[float] = [] for radius in sorted(radii): if radius <= 1e-6 or not math.isfinite(radius): continue if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): unique.append(radius) return unique def _loops_matching_owned_radii( loops: list[Dict[str, Any]], entities: list[Dict[str, Any]], owned_radii: list[float], ) -> list[Dict[str, Any]]: if not loops or not owned_radii: return [] matched: list[tuple[float, Dict[str, Any]]] = [] for loop in loops: candidates = _loop_radius_candidates(loop, entities) if not candidates: continue best_radius = None best_delta = float("inf") for candidate in candidates: for owned_radius in owned_radii: delta = abs(candidate - owned_radius) if delta < best_delta: best_delta = delta best_radius = candidate if best_radius is None: continue if best_delta <= max(0.1, best_radius * 0.01): matched.append((best_radius, loop)) if not matched: return [] matched.sort(key=lambda item: item[0], reverse=True) deduped: list[tuple[float, Dict[str, Any]]] = [] seen_loop_keys: set[str] = set() for radius, loop in matched: bbox = loop.get("bbox_mm") key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) key = f"{radius:.4f}:{key}" if key in seen_loop_keys: continue seen_loop_keys.add(key) deduped.append((radius, loop)) matched = deduped annotated = [] for index, (_, loop) in enumerate(matched): loop_copy = dict(loop) loop_copy["profile_mode"] = "add" if index == 0 else "subtract" annotated.append(loop_copy) return annotated def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: if not loops: return None area = 0.0 for index, loop in enumerate(loops): radii = _loop_radius_candidates(loop, entities) if not radii: return None radius = max(radii) mode = loop.get("profile_mode") sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 area += sign * math.pi * radius * radius return abs(area) if area > 1e-6 else None def _aligned_workplane_for_owned_midplane( sketch: Dict[str, Any], operation: Optional[Dict[str, Any]], loops: list[Dict[str, Any]], ) -> Dict[str, Any]: workplane = dict(sketch.get("workplane") or {}) if not isinstance(operation, dict) or operation.get("type") != "extrude_add": return workplane params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} if not params.get("both_directions"): return workplane entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] profile_area = _loop_area_from_radii(loops, entities) if profile_area is None: return workplane normal = workplane.get("normal") or [0, 0, 1] origin = workplane.get("origin_mm") or [0, 0, 0] if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: return workplane normal_vec = [float(v) for v in normal[:3]] norm = math.sqrt(sum(v * v for v in normal_vec)) if norm <= 1e-9: return workplane normal_vec = [v / norm for v in normal_vec] candidates: list[tuple[float, list[float]]] = [] for face in operation.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} if not surface.get("is_plane"): continue area_m2 = face.get("area_m2") plane_params = surface.get("plane_params") if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: continue face_area = abs(float(area_m2)) * 1_000_000 if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): continue plane_normal = [float(v) for v in plane_params[:3]] plane_norm = math.sqrt(sum(v * v for v in plane_normal)) if plane_norm <= 1e-9: continue plane_normal = [v / plane_norm for v in plane_normal] alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) if alignment < 0.98: continue plane_point = [float(v) * 1000 for v in plane_params[3:6]] old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) delta = new_offset - old_offset if abs(delta) <= 1e-6: continue moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] candidates.append((abs(delta), moved_origin)) if len(candidates) != 1: return workplane candidates.sort(key=lambda item: item[0]) workplane["origin_mm"] = candidates[0][1] return workplane def _project_owned_faces_to_sketch_bbox( owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] ) -> Optional[list[float]]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] y_dir = workplane.get("y_dir") or [0, 1, 0] if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: return None projected: list[tuple[float, float]] = [] for face in owned_faces: box = face.get("box_m") if isinstance(face, dict) else None if not isinstance(box, list) or len(box) < 6: continue mins = [float(box[i]) * 1000 for i in range(3)] maxs = [float(box[i + 3]) * 1000 for i in range(3)] for x in (mins[0], maxs[0]): for y in (mins[1], maxs[1]): for z in (mins[2], maxs[2]): point = [x, y, z] rel = [point[i] - float(origin[i]) for i in range(3)] projected.append(( sum(rel[i] * float(x_dir[i]) for i in range(3)), sum(rel[i] * float(y_dir[i]) for i in range(3)), )) if not projected: return None return [ min(point[0] for point in projected), min(point[1] for point in projected), max(point[0] for point in projected), max(point[1] for point in projected), ] def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: entities = sketch.get("entities", []) or [] loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) if not loops: return [] op_type = operation.get("type") if isinstance(operation, dict) else None if op_type == "extrude_cut" and len(loops) > 1: owned_bbox = _project_owned_faces_to_sketch_bbox( operation.get("source_owned_faces") or [], sketch.get("workplane") or {}, ) if owned_bbox: for inner in loops: inner_bbox = inner.get("bbox_mm") if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: continue containers = [ outer for outer in loops if outer is not inner and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 ] if containers: outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) outer_loop = dict(outer) inner_loop = dict(inner) outer_loop["profile_mode"] = "add" inner_loop["profile_mode"] = "subtract" return [outer_loop, inner_loop] active = [] for loop in loops: bbox = loop.get("bbox_mm") area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) contains_other = any( other is not loop and _bbox_contains_2d(bbox, other.get("bbox_mm")) and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 for other in loops ) if not contains_other: active.append(loop) if active: return active if op_type == "extrude_add" and len(loops) > 1: owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) # Owned-face radii are useful for selecting circular profiles, but a # rounded outer contour also contributes arc radii. Those radii can # coincide with an inner circle and make the radius ranking label the # inner loop as ADD and its containing outer loop as SUBTRACT. Such a # profile is topologically impossible as a first additive sketch, so # fall back to the complete contour nesting below. owned_modes_conflict_with_nesting = any( candidate.get("profile_mode") == "add" and any( container is not candidate and container.get("profile_mode") == "subtract" and _bbox_contains_2d( container.get("bbox_mm"), candidate.get("bbox_mm"), tolerance=1e-4, ) and _bbox_area_2d(container.get("bbox_mm")) > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 for container in owned_matched ) for candidate in owned_matched ) if owned_modes_conflict_with_nesting: owned_matched = [] if owned_matched: # Radius evidence cannot identify closed slot/polygon contours. # Keep non-circular closed loops that lie inside an owned additive # outer loop; they are material-removal islands in the same # additive sketch. Circular unmatched loops remain excluded # because they commonly belong to other features sharing a sketch. matched_entity_keys = { tuple(loop.get("entity_indices") or []) for loop in owned_matched } additive_outers = [ loop for loop in owned_matched if loop.get("profile_mode") == "add" ] for loop in loops: entity_indices = tuple(loop.get("entity_indices") or []) if entity_indices in matched_entity_keys: continue profile_entities = [ entities[index] for index in entity_indices if isinstance(index, int) and 0 <= index < len(entities) ] is_non_circular_profile = bool(profile_entities) and any( entity.get("type") != "circle" and not (entity.get("type") == "arc" and entity.get("is_circle")) for entity in profile_entities ) if not is_non_circular_profile: continue if not any( _bbox_contains_2d( outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 ) for outer in additive_outers ): continue loop_copy = dict(loop) loop_copy["profile_mode"] = "subtract" owned_matched.append(loop_copy) if len(owned_matched) == 1 and isinstance(operation, dict): outer_loop = owned_matched[0] outer_radii = _loop_radius_candidates(outer_loop, entities) outer_radius = max(outer_radii) if outer_radii else 0.0 outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 has_partial_cap = False for face in operation.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} area_m2 = face.get("area_m2") if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: face_area = abs(float(area_m2)) * 1_000_000 if face_area < outer_disk_area * 0.9: has_partial_cap = True break if has_partial_cap: inner_candidates = [ loop for loop in loops if loop is not outer_loop and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) ] if inner_candidates: inner = max( ( loop for loop in inner_candidates if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 ), key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), default=None, ) if inner is None: return owned_matched inner_radii = _loop_radius_candidates(inner, entities) inner_radius = max(inner_radii) if inner_radii else 0.0 if inner_radius <= 0: return owned_matched outer_copy = dict(outer_loop) inner_copy = dict(inner) outer_copy["profile_mode"] = "add" inner_copy["profile_mode"] = "subtract" return [outer_copy, inner_copy] return owned_matched annotated = [] for loop in loops: bbox = loop.get("bbox_mm") area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) containers = [ outer for outer in loops if outer is not loop and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 ] loop_copy = dict(loop) loop_copy["profile_mode"] = "subtract" if containers else "add" annotated.append(loop_copy) return annotated return loops def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: import math name = sketch.get("name", "Sketch") op_type = operation.get("type") if isinstance(operation, dict) else None workplane = sketch.get("workplane", {}) entities = sketch.get("entities", []) loops = _active_profile_loops(sketch, operation) workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) code = [f" # Sketch: {name}"] origin = workplane.get("origin_mm", [0, 0, 0]) x_dir = workplane.get("x_dir", [1, 0, 0]) normal = workplane.get("normal", [0, 0, 1]) if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: code.append( f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" ) else: code.append(" with BuildSketch() as sketch:") loop_entities = [] processed_indices = set() for loop in loops: for idx in loop.get("entity_indices", []): if idx < len(entities): loop_entities.append(entities[idx]) processed_indices.add(idx) append_unprocessed = not loops for i, ent in enumerate(entities): if append_unprocessed and i not in processed_indices: loop_entities.append(ent) drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] circle_entities = [ ent for ent in drawable_entities if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") ] wire_entities = [ ent for ent in drawable_entities if ent not in circle_entities and ent.get("type") in ("line", "arc") ] wire_entities = _ordered_wire_entities(wire_entities) handled_circle_entities = set() if not loops and len(circle_entities) > 1: ranked_circles = sorted( enumerate(circle_entities), key=lambda item: float(item[1].get("radius_mm", 0) or 0), reverse=True, ) outer_index, outer = ranked_circles[0] outer_center = outer.get("center", [0, 0, 0]) outer_radius = float(outer.get("radius_mm", 0) or 0) contains_all = outer_radius > 0 for _, inner in ranked_circles[1:]: inner_center = inner.get("center", [0, 0, 0]) inner_radius = float(inner.get("radius_mm", 0) or 0) center_distance = math.hypot( float(inner_center[0]) - float(outer_center[0]), float(inner_center[1]) - float(outer_center[1]), ) if center_distance + inner_radius >= outer_radius - 1e-6: contains_all = False break if contains_all: code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") code.append(f" Circle({outer_radius})") handled_circle_entities.add(outer_index) for inner_index, inner in ranked_circles[1:]: center = inner.get("center", [0, 0, 0]) radius = inner.get("radius_mm", 1) code.append(f" with Locations(({center[0]}, {center[1]})):") code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") handled_circle_entities.add(inner_index) def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: if op_type != "extrude_add" or not loops: return False center = ent.get("center", [0, 0]) radius = float(ent.get("radius_mm", 0) or 0) if radius <= 0 or len(center) < 2: return False bbox = [ float(center[0]) - radius, float(center[1]) - radius, float(center[0]) + radius, float(center[1]) + radius, ] return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) if not loops: for circle_index, ent in enumerate(circle_entities): if circle_index in handled_circle_entities: continue center = ent.get("center", [0, 0, 0]) radius = ent.get("radius_mm", 1) code.append(f" with Locations(({center[0]}, {center[1]})):") if circle_is_inner_profile(ent): code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") else: code.append(f" Circle({radius})") def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: """Orient contour segments into a continuous closed wire. SolidWorks contour arrays preserve membership but not necessarily each segment's traversal direction. Reversing an arc must also invert its curve axis; otherwise a short arc becomes its 270-degree complement. """ segments = [deepcopy(entity) for entity in profile_entities] if len(segments) < 2: return segments def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: start = entity.get("start") end = entity.get("end") if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): return None, None return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] def distance(left: list[float], right: list[float]) -> float: return math.hypot(left[0] - right[0], left[1] - right[1]) def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: reversed_entity = deepcopy(entity) reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") if isinstance(axis, list) and len(axis) >= 3: reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 reversed_entity.pop("start_angle_deg", None) reversed_entity.pop("end_angle_deg", None) reversed_entity.pop("arc_sweep_deg", None) if entity.get("type") == "arc": center = entity.get("center") or [0.0, 0.0] start = reversed_entity.get("start") or [0.0, 0.0] end = reversed_entity.get("end") or [0.0, 0.0] start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) reversed_sweep = end_angle - start_angle if reversed_sweep <= -180: reversed_sweep += 360 elif reversed_sweep > 180: reversed_sweep -= 360 reversed_entity["arc_sweep_deg"] = reversed_sweep return reversed_entity ordered = [segments.pop(0)] while segments: _, previous_end = endpoints(ordered[-1]) if previous_end is None: ordered.extend(segments) break candidates = [] for index, candidate in enumerate(segments): candidate_start, candidate_end = endpoints(candidate) if candidate_start is None or candidate_end is None: continue candidates.append((distance(previous_end, candidate_start), index, candidate)) candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) if not candidates: ordered.extend(segments) break _, selected_index, selected = min(candidates, key=lambda item: item[0]) ordered.append(selected) segments.pop(selected_index) return ordered def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: profile_entities = orient_wire_entities(profile_entities) code.append(" with BuildLine():") code.append(" pass") emitted_wire = False line_points = [] for line_ent in profile_entities: if line_ent.get("type") == "line": line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) line_bbox = None if line_points: xs = [float(point[0]) for point in line_points] ys = [float(point[1]) for point in line_points] line_bbox = (min(xs), min(ys), max(xs), max(ys)) for ent in profile_entities: ent_type = ent.get("type", "") if ent_type == "line": start = ent.get("start", [0, 0, 0]) end = ent.get("end", [0, 0, 0]) if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: code.append(" # Skip zero-length line") continue code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") emitted_wire = True elif ent_type == "arc": center = ent.get("center", [0, 0, 0]) radius = ent.get("radius_mm", 1) if "start_angle_deg" in ent and "end_angle_deg" in ent: start_angle = ent["start_angle_deg"] end_angle = ent["end_angle_deg"] else: start = ent.get("start", [0, 0]) end = ent.get("end", [0, 0]) start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) if ent.get("arc_sweep_deg") is not None: arc_size = float(ent["arc_sweep_deg"]) else: curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: if float(curve_axis[2]) >= 0: arc_size = (end_angle - start_angle) % 360 else: arc_size = -((start_angle - end_angle) % 360) else: arc_size = end_angle - start_angle if arc_size <= 0: arc_size += 360 if arc_size > 180: arc_size -= 360 code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") emitted_wire = True else: code.append(f" # TODO: entity type {ent_type}") if not emitted_wire: code.append(" # Skip empty wire profile") return if make_face_mode: code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") else: code.append(" make_face()") if loops: ordered_loops = sorted( enumerate(loops), key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), ) for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): profile_entities = [ entities[idx] for idx in loop.get("entity_indices", []) if idx < len(entities) and not entities[idx].get("construction", False) and entities[idx].get("type") in ("line", "arc", "circle") ] circle_profile_entities = [ ent for ent in profile_entities if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) ] wire_profile_entities = [ ent for ent in profile_entities if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities ] wire_profile_entities = _ordered_wire_entities(wire_profile_entities) if not profile_entities: continue mode = loop.get("profile_mode") if wire_profile_entities: append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) else: for ent in circle_profile_entities: center = ent.get("center", [0, 0, 0]) radius = ent.get("radius_mm", 1) code.append(f" with Locations(({center[0]}, {center[1]})):") if mode == "subtract": code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") else: code.append(f" Circle({radius})") elif wire_entities: append_wire_profile(wire_entities) return code def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: if not isinstance(sketch, dict): return [] radii = [] for entity in sketch.get("entities", []) or []: if entity.get("construction") or entity.get("type") != "circle": continue radius = float(entity.get("radius_mm") or 0) if radius > 0: radii.append(abs(radius)) return radii def _flip_side_step_inner_radius_mm( op: Dict[str, Any], sketch: Optional[Dict[str, Any]], operations: list[Dict[str, Any]], sketches: Dict[str, Dict[str, Any]], ) -> Optional[float]: outer_radii = _sketch_circle_radii_mm(sketch) if not outer_radii: return None outer = max(outer_radii) if len(outer_radii) > 1: return min(outer_radii) inner = None try: op_index = operations.index(op) except ValueError: op_index = len(operations) for prev in operations[:op_index]: if prev.get("type") != "extrude_cut": continue if not (prev.get("parameters") or {}).get("flip_side_to_cut"): continue prev_sketch = sketches.get(prev.get("sketch") or "", {}) for radius in _sketch_circle_radii_mm(prev_sketch): if radius < outer - 1e-6: inner = radius if inner is None else max(inner, radius) return inner def _flip_side_uses_step_ring( op: Dict[str, Any], sketch: Optional[Dict[str, Any]], operations: list[Dict[str, Any]], sketches: Dict[str, Dict[str, Any]], ) -> tuple[Optional[float], Optional[float]]: outer_radii = _sketch_circle_radii_mm(sketch) if not outer_radii: return None, None outer = max(outer_radii) inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) if inner is None or outer <= inner + 0.5: return None, None if outer < 35 and outer / inner < 1.5: return None, None return outer, inner def _effective_extrude_cut_depth_mm( op: Dict[str, Any], sketch: Optional[Dict[str, Any]], distance_mm: float, ) -> float: params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} if not params.get("flip_side_to_cut"): return distance_mm workplane = (sketch or {}).get("workplane") or {} origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] normal = workplane.get("normal") or [0.0, 0.0, 1.0] if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: return distance_mm axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) cut_sign = -1.0 if cut_amount < 0 else 1.0 owned_values = [] for face in op.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} if not surface.get("is_plane"): continue box = face.get("box_m") if not isinstance(box, list) or len(box) < 6: continue thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] if min(thicknesses) > 0.5: continue owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) if not owned_values: return distance_mm transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 effective = abs(float(origin[axis]) - transition) if effective <= 1e-6: return distance_mm if abs(effective - abs(distance_mm)) <= 0.25: return distance_mm # Guard: owned-face depth can be wrong when all owned faces # are near the sketch plane (e.g., edge details), not at the # real cut termination. Fall back to a through-cut distance # so the invert-cutter extends past the entire body. if effective < max(2.0, abs(distance_mm) * 0.15): return max(distance_mm, THROUGH_CUT_AMOUNT_MM) return effective def _owned_extrude_terminal_offsets_mm( op: Dict[str, Any], sketch: Optional[Dict[str, Any]], ) -> tuple[Optional[float], Optional[float]]: """Return the nearest owned planar end faces along the sketch normal. SolidWorks can report a two-sided feature with a stale blind depth when one side terminates on geometry. The feature-owned end face is the reliable result geometry: its signed offset from the sketch plane identifies the actual termination direction and distance. """ workplane = (sketch or {}).get("workplane") or {} origin = workplane.get("origin_mm") or [] normal = workplane.get("normal") or [] if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): return None, None magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) if magnitude <= 1e-9: return None, None unit_normal = [float(value) / magnitude for value in normal[:3]] positive: list[float] = [] negative: list[float] = [] for face in op.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} if not surface.get("is_plane"): continue params = surface.get("plane_params") if not isinstance(params, list) or len(params) < 6: continue point_mm = [float(value) * 1000 for value in params[3:6]] offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) if offset > 1e-4: positive.append(offset) elif offset < -1e-4: negative.append(offset) return (max(positive) if positive else None, min(negative) if negative else None) def _resolve_extrude_owned_termination( op: Dict[str, Any], sketch: Optional[Dict[str, Any]], ) -> Dict[str, Any]: """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} if op.get("type") != "extrude_add" or not params.get("both_directions"): return op positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) if (positive is None) == (negative is None): return op resolved = dict(op) resolved_params = dict(params) resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) resolved_params["reverse_distance_mm"] = 0 resolved_params["both_directions"] = False resolved_params["reverse_direction"] = negative is not None resolved_params["owned_termination_resolved"] = True resolved["parameters"] = resolved_params return resolved def _generate_extrude( op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None, operations: Optional[list[Dict[str, Any]]] = None, sketches: Optional[Dict[str, Dict[str, Any]]] = None, ) -> list[str]: params = op.get("parameters", {}) distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) reverse_distance = params.get("reverse_distance_mm", 0) op_type = op.get("type", "") name = op.get("name", "") both_directions = params.get("both_directions", False) flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) end_condition_code = params.get("end_condition_code") reverse_end_condition_code = params.get("reverse_end_condition_code") end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") operations = operations or [] sketches = sketches or {} outer_radius, inner_radius = ( _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) ) resolved_owned_termination = bool(params.get("owned_termination_resolved")) code = [f" # {op_type}: {name}"] if resolved_owned_termination: code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" if end_condition_code is not None: code.append(f" # SW end condition: {end_condition}") owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) prefer_blind_sketch = _prefer_blind_sketch_extrude( op, sketch or {}, distance, end_condition_code, owned_cylinder_faces ) if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") return code if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") return code owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") return code if distance == 0 and reverse_distance == 0: if op_type == "extrude_cut" and end_condition_code not in (None, 0): distance = THROUGH_CUT_AMOUNT_MM both_directions = end_condition_code in (1, 2, 9) code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") else: code.append(" # Skip: zero distance") return code elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): code.append(" # SW mid-plane/two-sided extrusion represented by this IR") distance = distance / 2 reverse_distance = distance both_directions = True elif op_type == "extrude_cut" and end_condition_code not in (None, 0): distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) both_directions = both_directions or end_condition_code in (1, 2, 9) code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") if both_directions: amount = max(distance, reverse_distance) if reverse_distance > 0 else distance if op_type == "extrude_cut": code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") if flip_side_to_cut: normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) if outer_radius is not None and inner_radius is not None: code.append( " result = sw_flip_side_step_cut(" f"result, cutter, normal={_tuple3(normal)}, " f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" ) else: code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") else: code.append(" result = safe_subtract(result, cutter)") else: code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") elif op_type == "extrude_cut": if distance > 0: cut_amount = distance if params.get("reverse_direction", False) else -distance code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 if prefer_blind_sketch: face_offset = _blind_extrude_face_offset(op, sketch or {}) if face_offset is not None: code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") if flip_side_to_cut: normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) if outer_radius is not None and inner_radius is not None: code.append( " result = sw_flip_side_step_cut(" f"result, cutter, normal={_tuple3(normal)}, " f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" ) else: code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") else: code.append(" result = safe_subtract(result, cutter)") else: code.append(" # Skip: zero distance cut") else: add_amount = -distance if params.get("reverse_direction", False) else distance code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") return code def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: if op.get("type") != "extrude_cut": return [] sketch_radii = [ abs(float(entity.get("radius_mm") or 0)) for entity in sketch.get("entities", []) or [] if not entity.get("construction") and entity.get("type") == "circle" ] if not sketch_radii: return [] matched = [] for face in op.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} params = surface.get("cylinder_params") bbox = face.get("box_m") if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): continue if not (isinstance(bbox, list) and len(bbox) >= 6): continue radius_mm = abs(float(params[6]) * 1000) if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): continue matched.append(face) return matched def _blind_extrude_face_offset( op: Dict[str, Any], sketch: Dict[str, Any], ) -> Optional[list[float]]: """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 返回None表示不需要平移。""" faces = (op.get("source_owned_faces") or []) if not faces: return None valid_bboxes = [] for face in faces: bm = face.get("box_m") if isinstance(bm, list) and len(bm) >= 6: valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) if not valid_bboxes: return None normal = (sketch.get("workplane") or {}).get("normal") if not isinstance(normal, list) or len(normal) < 3: return None origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] # 确定主导轴 (extrude方向) axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 # 对于多个面,面可能在草图平面两侧。 all_coords = [] for b in valid_bboxes: all_coords.append(b[axis]) all_coords.append(b[axis + 3]) if not all_coords: return None # 找离sketch_coord最近的面坐标 face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) offset = face_coord - sketch_coord if abs(offset) < 1e-3: return None # 返回3D平移向量(仅沿extrude方向) result = [0.0, 0.0, 0.0] result[axis] = offset return result def _prefer_blind_sketch_extrude( op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float, end_condition_code: Optional[int], owned_cylinder_faces: list[Dict[str, Any]], ) -> bool: """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 产生意外偏差,此时仍走 bbox 路径。""" if owned_cylinder_faces: return False if end_condition_code not in (None, 0) or distance_mm <= 0: return False if not _sketch_has_buildable_profile(sketch): return False # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 entities = sketch.get("entities", []) or [] non_const = [e for e in entities if not e.get("construction", False)] types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} # 排除point/text后仍是简单截面才用盲拉伸。 # 但如果面位于不同平面,让_blind_extrude_face_offset处理 is_simple = types <= {"line"} or types <= {"circle"} if not is_simple: return False # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 faces = op.get("source_owned_faces") or [] if faces and _blind_extrude_face_offset(op, sketch) is not None: return True # 有面偏移,需要盲拉伸+offset修正 # 无面偏移时,只有当start/end引用完整时才用盲拉伸 if op.get("start_reference") or op.get("end_reference"): return True return False def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: if op.get("type") != "extrude_cut": return None faces = [ face for face in (op.get("source_owned_faces") or []) if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 ] if not faces: return None bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] bbox = [ min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) for axis in range(6) ] normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] if not isinstance(normal, list) or len(normal) < 3: return None axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) extent = abs(bbox[axis + 3] - bbox[axis]) origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None distance = abs(float(distance_mm or 0)) origin_outside = ( origin_coord is not None and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) ) if extent <= distance * 1.25 and not origin_outside: return None return bbox def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: params = op.get("parameters", {}) angle = params.get("angle_deg") if angle is None and params.get("angle_rad") is not None: angle = float(params.get("angle_rad")) * 180 / math.pi if angle is None: angle = 360 if abs(angle - 360) < 1e-6: angle = 360 op_type = op.get("type", "") name = op.get("name", "") code = [f" # {op_type}: {name}"] axis_expr = _revolve_axis_expr(params, sketch or {}) code.append(f" revolve_axis = {axis_expr}") if op_type == "revolve_cut": code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") code.append(" result = safe_subtract(result, cutter)") else: code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") preserve_visible = bool(op.get("source_owned_faces")) code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") return code def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: # 优先使用草图中的构造线作为旋转轴, # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) construction_axis = _sketch_construction_axis(sketch) if construction_axis: origin, direction = construction_axis return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" axis_reference = params.get("axis_reference") or {} if axis_reference.get("origin_mm") and axis_reference.get("direction"): return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" for candidate in params.get("axis_candidates") or []: if candidate.get("model_start_mm") and candidate.get("model_direction"): return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" workplane = sketch.get("workplane", {}) origin = workplane.get("origin_mm", [0, 0, 0]) direction = workplane.get("x_dir", [1, 0, 0]) return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" def _sketch_construction_axis( sketch: Dict[str, Any], ) -> Optional[tuple[list[float], list[float]]]: workplane = sketch.get("workplane", {}) origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] for entity in sketch.get("entities", []): if entity.get("type") != "line" or not entity.get("construction"): continue start = entity.get("start") end = entity.get("end") if not start or not end: continue start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) direction = [end_3d[i] - start_3d[i] for i in range(3)] length = math.sqrt(sum(component * component for component in direction)) if length <= 0: continue return start_3d, [component / length for component in direction] return None def _sketch_point_to_model_from_basis( origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] ) -> list[float]: return [ origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) for i in range(3) ] def _generate_fillet(op: Dict[str, Any]) -> list[str]: params = op.get("parameters", {}) radius = params.get("radius_mm") selectors = op.get("selectors", []) owned_faces = op.get("source_owned_faces") or [] if not radius or float(radius) <= 0: return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] return [ f" # Fillet: {op.get('name', '')}", " result = fillet_selected(" f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", ] def _generate_chamfer(op: Dict[str, Any]) -> list[str]: params = op.get("parameters", {}) distance = params.get("distance_mm") selectors = op.get("selectors", []) owned_faces = op.get("source_owned_faces") or [] if not distance or float(distance) <= 0: return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] return [ f" # Chamfer: {op.get('name', '')}", " result = chamfer_selected_with_owned_faces(" f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", ] def _generate_move_face(op: Dict[str, Any]) -> list[str]: data = (op.get("parameters") or {}).get("move_face_data") or {} selected_faces = data.get("selected_faces") or [] return [ f" # MoveFace pure-JSON operation: {op.get('name', '')}", " raise NotImplementedError(", f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", " )", ] def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: positions = params.get("positions") or [] diameter = _hole_diameter_mm(params) if not positions or diameter <= 0: return False if len(owned_cut_faces) <= 1: return False has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) drill_angle = _hole_drill_angle_rad(params) if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): return False counterbore_diameter = _hole_counterbore_diameter_mm(params) counterbore_depth = _hole_counterbore_depth_mm(params) if counterbore_diameter > diameter and counterbore_depth > 0: return True return _hole_has_through_dimension(params) def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: if _hole_has_through_dimension(params): return THROUGH_CUT_AMOUNT_MM return _hole_depth_mm(params) def _generate_hole(op: Dict[str, Any]) -> list[str]: params = op.get("parameters", {}) diameter = _hole_diameter_mm(params) depth = _effective_hole_cut_depth_mm(params) drill_angle = _hole_drill_angle_rad(params) include_drill_tip = _hole_has_drill_tip(params) countersink_diameter = _hole_countersink_diameter_mm(params) countersink_angle = _hole_countersink_angle_rad(params) counterbore_diameter = _hole_counterbore_diameter_mm(params) counterbore_depth = _hole_counterbore_depth_mm(params) positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] host_face = params.get("host_face") or {} owned_cut_faces = _hole_owned_cut_faces(op) # Feature position sketches are occasionally incomplete in the plugin export # (notably for wizard holes with multiple instances). The faces owned by the # feature are the authoritative result from SolidWorks, including every hole # location, counterbore, countersink, and drill tip. Prefer replaying those # surfaces whenever they are available; fall back to the parametric cutter # only when the exporter has no usable owned-face geometry. if owned_cut_faces: return [ f" # Hole: {op.get('name', '')}", " # Replay hole from SW owned cut faces to preserve side and axis", f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", ] return [ f" # Hole: {op.get('name', '')}", f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", ] def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: matched = [] for face in op.get("source_owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} bbox = face.get("box_m") has_cylinder = ( surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list) and len(surface.get("cylinder_params") or []) >= 7 ) has_cone = ( surface.get("is_cone") and isinstance(surface.get("cone_params"), list) and len(surface.get("cone_params") or []) >= 8 ) if not (has_cylinder or has_cone): continue if not (isinstance(bbox, list) and len(bbox) >= 6): continue matched.append(face) return matched def _generate_linear_pattern( op: Dict[str, Any], operations: list[Dict[str, Any]], sketches: Dict[str, Dict[str, Any]], references: Dict[str, Any], ) -> list[str]: params = op.get("parameters", {}) source_features = params.get("source_features") or [] offsets = _linear_pattern_offsets(op) code = [f" # Linear pattern: {op.get('name', '')}"] if not source_features or not offsets: code.append(" # Skip: no source features or pattern offsets") return code for source_feature in source_features: source_op = _find_operation_for_source_feature(operations, source_feature) if not source_op: code.append(f" # Skip: source feature not found {source_feature.get('name')}") continue for offset_index, offset in enumerate(offsets, start=1): copied_op = _translated_operation(source_op, offset) copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" op_type = copied_op.get("type") if op_type == "hole": code.extend(_generate_hole(copied_op)) elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): source_sketch_id = copied_op.get("sketch") source_sketch = sketches.get(source_sketch_id or "") if not source_sketch: code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") continue if not _sketch_has_buildable_profile(source_sketch): code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") continue copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") code.extend(_generate_sketch(copied_sketch, references, copied_op)) if op_type in ("extrude_cut", "extrude_add"): code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) else: code.extend(_generate_revolve(copied_op, copied_sketch)) else: code.append(f" # TODO: pattern source type {op_type}") return code def _generate_mirror_pattern( op: Dict[str, Any], operations: list[Dict[str, Any]], sketches: Dict[str, Dict[str, Any]], references: Dict[str, Any], ) -> list[str]: """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" params = op.get("parameters", {}) source_features = params.get("source_features") or [] raw = op.get("raw_parameters", {}) mirror_plane_info = raw.get("mirror_plane") or {} code = [f" # Mirror pattern: {op.get('name', '')}"] plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) mx = plane_origin[0] if plane_origin else 0.0 my = plane_origin[1] if plane_origin else 0.0 mz = plane_origin[2] if plane_origin else 0.0 nx = plane_normal[0] if plane_normal else 0.0 ny = plane_normal[1] if plane_normal else 0.0 nz = plane_normal[2] if plane_normal else 1.0 code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") code.append(f" mx, my, mz = {mx}, {my}, {mz}") code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") code.append(f" try:") code.append(f" bbox = result.bounding_box()") code.append(f" margin = 10.0") # Determine dominant axis and cut away the -normal side adx, ady, adz = abs(nx), abs(ny), abs(nz) if adx >= ady and adx >= adz: if nx > 0: code.append(f" cut_w = (mx - bbox.min.X) + margin") code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") else: code.append(f" cut_w = (bbox.max.X - mx) + margin") code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") elif ady >= adx and ady >= adz: if ny > 0: code.append(f" cut_h = (my - bbox.min.Y) + margin") code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") else: code.append(f" cut_h = (bbox.max.Y - my) + margin") code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") else: if nz > 0: code.append(f" cut_d = (mz - bbox.min.Z) + margin") code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") else: code.append(f" cut_d = (bbox.max.Z - mz) + margin") code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") code.append(f" half = result.cut(cut_box)") code.append(f" mirrored = half.mirror(mirror_plane)") code.append(f" result = half.fuse(mirrored).clean()") code.append(f" except Exception as e:") code.append(f" print(f'mirror failed: {{e}}')") return code def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): mir_origin = raw.get("mirror_plane_origin") if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] if origin_list and len(origin_list) >= 3: return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) frame = mirror_plane_info.get("frame") if isinstance(frame, dict): origin_list = frame.get("origin") or [] if origin_list and len(origin_list) >= 3: return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) return None def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): mir_normal = raw.get("mirror_plane_normal") if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) normal_list = mirror_plane_info.get("normal") or [] if normal_list and len(normal_list) >= 3: return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) frame = mirror_plane_info.get("frame") if isinstance(frame, dict): normal_list = frame.get("normal") or [] if normal_list and len(normal_list) >= 3: return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) return None def _find_operation_for_source_feature( operations: list[Dict[str, Any]], source_feature: Dict[str, Any] ) -> Optional[Dict[str, Any]]: source_index = source_feature.get("index") source_name = source_feature.get("name") source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") for op in operations: op_source = op.get("source_feature", {}) if source_index is not None and op_source.get("index") == source_index: return op for op in operations: op_source = op.get("source_feature", {}) op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} if source_stable_id and ( op_source.get("stable_id") == source_stable_id or op_identity.get("stable_id") == source_stable_id ): return op if source_persistent_reference and ( op_source.get("persistent_reference") == source_persistent_reference or op_identity.get("persistent_reference") == source_persistent_reference ): return op for op in operations: if source_name and op.get("name") == source_name: return op return None def _find_source_operation_for_pattern( operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: for source_feature in source_features: source_op = _find_operation_for_source_feature(operations, source_feature) if source_op: return source_op return None def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: params = op.get("parameters", {}) raw = op.get("raw_parameters", {}) explicit_offsets = raw.get("explicit_offsets_mm") if isinstance(explicit_offsets, list) and explicit_offsets: return [ (float(offset[0]), float(offset[1]), float(offset[2])) for offset in explicit_offsets if isinstance(offset, list) and len(offset) >= 3 ] d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) d2_count = int(raw.get("d2_total_instances") or 1) d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) d2_spacing = float(raw.get("d2_spacing_mm") or 0) d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) offsets = [] for i in range(d1_count): for j in range(d2_count): if i == 0 and j == 0: continue offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) return offsets def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: if not direction or not spacing: return (0.0, 0.0, 0.0) direct_vector = direction.get("vector") if isinstance(direct_vector, list) and len(direct_vector) >= 3: vector = tuple(float(direct_vector[i]) for i in range(3)) length = math.sqrt(sum(component * component for component in vector)) if length <= 0: return (0.0, 0.0, 0.0) return tuple(component / length * spacing for component in vector) start = direction.get("start", {}).get("mm") end = direction.get("end", {}).get("mm") if not start or not end: return (0.0, 0.0, 0.0) vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) length = math.sqrt(sum(component * component for component in vector)) if length <= 0: return (0.0, 0.0, 0.0) return tuple(component / length * spacing for component in vector) def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: copied = deepcopy(op) params = copied.get("parameters") or {} axis_reference = params.get("axis_reference") if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) origin = (origin + [0, 0, 0])[:3] axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] if copied.get("type") == "hole": positions = params.get("positions") or [] local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) for position in positions: if position.get("mm"): point = list(position.get("mm") or [0, 0, 0]) point = (point + [0, 0, 0])[:3] position["mm"] = [ float(point[0]) + local_offset[0], float(point[1]) + local_offset[1], float(point[2]) + local_offset[2], ] if position.get("m"): position["m"] = [value / 1000 for value in position.get("mm", [])] if any(abs(float(offset[i])) > 1e-9 for i in range(3)): owned_faces = copied.get("source_owned_faces") or [] if owned_faces: copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) return copied def _translate_owned_faces( faces: list[Dict[str, Any]], offset: tuple[float, float, float], ) -> list[Dict[str, Any]]: translated = [] shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) for face in faces: if not isinstance(face, dict): continue copied = deepcopy(face) box = copied.get("box_m") if isinstance(box, list) and len(box) >= 6: copied["box_m"] = [ float(box[0]) + shift_m[0], float(box[1]) + shift_m[1], float(box[2]) + shift_m[2], float(box[3]) + shift_m[0], float(box[4]) + shift_m[1], float(box[5]) + shift_m[2], ] surface = copied.get("surface") if isinstance(surface, dict): for key in ("cylinder_params", "cone_params"): params = surface.get(key) if isinstance(params, list) and len(params) >= 3: updated = list(params) updated[0] = float(updated[0]) + shift_m[0] updated[1] = float(updated[1]) + shift_m[1] updated[2] = float(updated[2]) + shift_m[2] surface[key] = updated translated.append(copied) return translated def _model_offset_to_host_local( offset: tuple[float, float, float], host_face: Dict[str, Any], ) -> tuple[float, float, float]: frame = host_face.get("frame") if isinstance(host_face, dict) else {} if not isinstance(frame, dict): return offset x_dir = frame.get("x_dir") y_dir = frame.get("y_dir") if not ( isinstance(x_dir, list) and len(x_dir) >= 3 and isinstance(y_dir, list) and len(y_dir) >= 3 ): return offset local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) return (local_x, local_y, 0.0) def _translated_sketch( sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str ) -> Dict[str, Any]: copied = deepcopy(sketch) copied["id"] = sketch_id copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" workplane = copied.setdefault("workplane", {}) origin = list(workplane.get("origin_mm") or [0, 0, 0]) origin = (origin + [0, 0, 0])[:3] workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] return copied def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: dx, dy = offset[0], offset[1] for entity in sketch.get("entities", []): for key in ("start", "end", "center"): point = entity.get(key) if isinstance(point, list) and len(point) >= 2: point[0] = float(point[0]) + dx point[1] = float(point[1]) + dy raw = entity.get("raw", {}) for key in ("start", "end", "center"): raw_point = raw.get(key) if isinstance(raw_point, dict): mm = raw_point.get("mm") if isinstance(mm, list) and len(mm) >= 2: mm[0] = float(mm[0]) + dx mm[1] = float(mm[1]) + dy raw_point["m"] = [value / 1000 for value in mm] def _hole_diameter_mm(params: Dict[str, Any]) -> float: if params.get("diameter_mm"): return float(params["diameter_mm"]) diameters = params.get("diameters_m", {}) for key in ( "hole_diameter", "thru_hole_diameter", "tap_drill_diameter", "thru_tap_drill_diameter", "thread_diameter", "diameter", ): value = diameters.get(key) if value: return float(value) * 1000 return 0 def _hole_depth_mm(params: Dict[str, Any]) -> float: if params.get("depth_mm"): return float(params["depth_mm"]) depths = params.get("depths_m", {}) for key in ( "hole_depth", "thru_hole_depth", "tap_drill_depth", "thru_tap_drill_depth", "thread_depth", "depth", ): value = depths.get(key) if value: return float(value) * 1000 return THROUGH_CUT_AMOUNT_MM def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: angle = params.get("angles_rad", {}).get("drill_angle") return float(angle) if angle else 0 def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: angle = params.get("angles_rad", {}).get("countersink_angle") return float(angle) if angle else 0 def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: diameter = params.get("countersink_diameter_mm") return float(diameter) if diameter else 0 def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: diameter = params.get("counterbore_diameter_mm") return float(diameter) if diameter else 0 def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: depth = params.get("counterbore_depth_mm") return float(depth) if depth else 0 def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: depths = params.get("depths_m", {}) angle = _hole_drill_angle_rad(params) if angle <= 0: return False through_depth_keys = ( "thru_hole_depth", "thru_tap_drill_depth", ) if any(depths.get(key) for key in through_depth_keys): return False if params.get("depth_mm"): return True return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) return any(token in names for token in ("通孔", "through", "thru")) def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: for dim in data_block.get("dimensions", []) or []: name = str(dim.get("name") or "").lower() if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): return float(dim.get("value")) return None def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: candidates: list[tuple[int, float]] = [] for dim in feature.get("dimensions") or []: if not isinstance(dim, dict): continue name = str(dim.get("name") or "") system_value = dim.get("system_value_m") if system_value not in (None, ""): length_mm = abs(float(system_value)) * 1000 elif dim.get("value") not in (None, ""): length_mm = abs(float(dim.get("value"))) else: continue if length_mm <= 1e-9 or length_mm > 500: continue priority = 0 if name.startswith("D1@") else 1 candidates.append((priority, length_mm)) if not candidates: return None candidates.sort(key=lambda item: (item[0], item[1])) return candidates[0][1] def _feature_selection_selectors( feature: Dict[str, Any], data_block: Optional[Dict[str, Any]] = None, ) -> list[Dict[str, Any]]: selectors: list[Dict[str, Any]] = [] seen: set[str] = set() sources = [] if isinstance(data_block, dict): sources.extend(data_block.get("selections") or []) sources.extend(feature.get("selections") or []) for selection in sources: if not isinstance(selection, dict) or selection.get("kind") != "selection": continue geometry = selection.get("object") if not isinstance(geometry, dict): continue kind = geometry.get("kind") if kind not in ("edge", "face"): continue identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} stable_key = ( geometry.get("stable_id") or geometry.get("persistent_reference") or identity.get("stable_id") or identity.get("persistent_reference") or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) ) if stable_key in seen: continue seen.add(str(stable_key)) selectors.append({ "kind": kind, "geometry": geometry, "mark": selection.get("mark"), "source_feature": { "name": selection.get("feature_name"), "type_name": selection.get("feature_type_name"), }, }) if selectors: return selectors for face in feature.get("owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} cylinder_params = surface.get("cylinder_params") if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): continue radius_mm = abs(float(cylinder_params[6]) * 1000) line_params = [ float(cylinder_params[0]), float(cylinder_params[1]), float(cylinder_params[2]), float(cylinder_params[3]), float(cylinder_params[4]), float(cylinder_params[5]), ] stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" if stable_key in seen: continue seen.add(stable_key) selectors.append({ "kind": "edge", "geometry": { "kind": "edge", "curve": { "kind": "curve", "is_line": True, "line_params": line_params, }, "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 else None, }, "tolerance_mm": max(0.5, radius_mm * 2.5), "source": "owned_cylindrical_face_axis", }) for face in feature.get("owned_faces") or []: if not isinstance(face, dict): continue box_m = face.get("box_m") if not (isinstance(box_m, list) and len(box_m) >= 6): continue bbox_mm = [float(value) * 1000 for value in box_m[:6]] if any(not math.isfinite(value) for value in bbox_mm): continue sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" if stable_key in seen: continue seen.add(stable_key) selectors.append({ "kind": "edge", "geometry": { "kind": "edge", "bbox_mm": bbox_mm, }, "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), "source": "owned_face_bbox", }) return selectors def _feature_selection_source( feature: Dict[str, Any], data_block: Optional[Dict[str, Any]] = None, ) -> str: sources = [] if isinstance(data_block, dict): sources.extend(data_block.get("selections") or []) sources.extend(feature.get("selections") or []) if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): return "solidworks_original_selection" if feature.get("owned_faces"): return "post_feature_owned_face_inference" return "missing" def _hole_dimension_value_excluding( data_block: Dict[str, Any], tokens: tuple[str, ...], excluded: tuple[str, ...] = (), ) -> Optional[float]: for dim in data_block.get("dimensions", []) or []: name = str(dim.get("name") or "").lower() if excluded and any(token.lower() in name for token in excluded): continue if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): return float(dim.get("value")) return None def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: values = [] for dim in data_block.get("dimensions", []) or []: name = str(dim.get("name") or "").lower() if not any(token in name for token in ("孔", "hole", "螺", "thread")): continue if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): continue value = dim.get("value") if value in (None, ""): continue number = abs(float(value)) if 0 < number < 200: values.append(number) if not values: return None return min(values) if prefer_small else max(values) def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: diameter = ( _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) or _hole_primary_dimension_fallback(data_block, prefer_small=True) ) return abs(float(diameter)) if diameter else 0 def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: depth = ( _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) ) if depth: return abs(float(depth)) return THROUGH_CUT_AMOUNT_MM def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: return ( _hole_dimension_value(data_block, ("柱形沉头", "直径")) or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) or _hole_dimension_value(data_block, ("沉头孔", "直径")) or _hole_dimension_value(data_block, ("counterbore", "diameter")) or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) ) def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: return ( _hole_dimension_value(data_block, ("柱形沉头", "深度")) or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) or _hole_dimension_value(data_block, ("沉头孔", "深度")) or _hole_dimension_value(data_block, ("counterbore", "depth")) or _hole_dimension_value(data_block, ("counter", "bore", "depth")) ) def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: for dim in data_block.get("dimensions", []) or []: name = str(dim.get("name") or "").lower() if all(token.lower() in name for token in tokens): if dim.get("system_value_m") not in (None, ""): return float(dim.get("system_value_m")) if dim.get("value") not in (None, ""): value = float(dim.get("value")) return value / 1000 if value > math.tau else value return None def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: selector_points = [] for selector in op.get("selectors", []): geometry = selector.get("geometry") or {} start = geometry.get("start_vertex") or {} start_point = start.get("point_m") if isinstance(start, dict) else None end = geometry.get("end_vertex") or {} end_point = end.get("point_m") if isinstance(end, dict) else None if start_point and end_point: selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) return selector_points _SW_METADATA_FEATURE_TYPES = { "commentsfolder", "favoritefolder", "historyfolder", "selectionsetfolder", "sensorfolder", "docsfolder", "detailcabinet", "surfacebodyfolder", "solidbodyfolder", "envfolder", "inkmarkupfolder", "eqnfolder", "materialfolder", "configtablefolder", "ftrfolder", } def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} return { "index": source.get("index", index), "id": feature.get("id"), "name": feature.get("name"), "type": feature.get("type"), "type_name": feature.get("type_name"), "stable_id": source.get("stable_id") or identity.get("stable_id"), "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), "identity": identity or None, } def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: faces = feature.get("owned_faces") if not isinstance(faces, list): return [] summarized = [] for face in faces: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} summarized.append( { "box_m": face.get("box_m"), "area_m2": face.get("area_m2"), "surface": { "is_plane": bool(surface.get("is_plane")), "is_cylinder": bool(surface.get("is_cylinder")), "is_cone": bool(surface.get("is_cone")), "is_sphere": bool(surface.get("is_sphere")), "is_torus": bool(surface.get("is_torus")), "cylinder_params": surface.get("cylinder_params"), "cone_params": surface.get("cone_params"), "plane_params": surface.get("plane_params"), }, } ) return summarized def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: snapshot = feature.get("definition_snapshot", {}) return { "id": feature.get("id") or f"reference_{index:03d}", "name": feature.get("name"), "type": feature.get("type"), "sw_type": feature.get("type_name"), "definition": snapshot.get("values", {}), "source_feature": _source_feature(feature, index), } def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: sketch_data = feature.get("sketch_data", {}) raw_entities = sketch_data.get("entities", []) raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] converted_entities = [] raw_to_converted_index: dict[int, int] = {} stable_id_to_raw_index: dict[str, int] = {} for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): for stable_id in _selectable_stable_ids(raw_entity): stable_id_to_raw_index.setdefault(stable_id, raw_index) if converted_entity is None: continue raw_to_converted_index[raw_index] = len(converted_entities) converted_entities.append(converted_entity) loops = [] for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: if not isinstance(contour, dict): continue entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] if not entity_indices: entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) if not entity_indices: continue normalized_indices = [ raw_to_converted_index[int(idx)] for idx in entity_indices if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index ] if not normalized_indices: continue bbox = _loop_bbox([ converted_entities[idx] for idx in normalized_indices if 0 <= idx < len(converted_entities) ]) loops.append({ "id": contour.get("contour_id"), "entity_indices": normalized_indices, "is_closed": contour.get("is_closed"), "bbox_mm": bbox or contour.get("bbox_mm"), "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), "source": "solidworks_sketch_contour", }) workplane = sketch_data.get("workplane") or {} if not workplane: workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} return { "id": sketch_id, "name": feature.get("name", sketch_id), "workplane": workplane, "host_reference": sketch_data.get("host_reference"), "entities": converted_entities, "loops": loops, "sketch_regions": sketch_data.get("sketch_regions", []), "constraints": sketch_data.get("constraints", []), "inferred_constraints": sketch_data.get("inferred_constraints", []), "dimensions": sketch_data.get("dimensions", []), "feature_dimensions": sketch_data.get("feature_dimensions", []), "source_feature": _source_feature(feature, index), } def _selectable_stable_ids(value: Any) -> list[str]: if not isinstance(value, dict): return [] candidates = [value.get("stable_id")] identity = value.get("identity") if isinstance(identity, dict): candidates.append(identity.get("stable_id")) return [str(candidate) for candidate in candidates if candidate] def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: indices: list[int] = [] seen: set[int] = set() for segment in contour.get("sketch_segments") or []: for stable_id in _selectable_stable_ids(segment): raw_index = stable_id_to_raw_index.get(stable_id) if raw_index is None or raw_index in seen: continue seen.add(raw_index) indices.append(raw_index) break return indices def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} if ( entity_type == "circle_or_arc" or curve.get("is_circle") is True or entity.get("curve_entity_type") == "circle_or_arc" ): center = entity.get("curve_center_mm") or entity.get("center_mm") radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") radius_raw_value = entity.get("radius") start = entity.get("start_mm") end = entity.get("end_mm") start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: # 计算 sweep 方向 import math as _math sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) sweep = round(ea - sa, 10) while sweep <= -180: sweep += 360 while sweep > 180: sweep -= 360 result = { "type": "arc", "center": center_2d, "start": start_2d, "end": end_2d, "radius_mm": radius_mm, "start_angle_deg": round(sa, 10), "end_angle_deg": round(ea, 10), "arc_sweep_deg": round(sweep, 10), "construction": bool(entity.get("construction")), "raw": entity, } curve_axis = entity.get("curve_axis") if isinstance(curve_axis, list) and len(curve_axis) >= 3: result["curve_axis"] = [float(v) for v in curve_axis[:3]] return result return { "type": "circle", "center": center_2d, "radius_mm": radius_mm, "construction": bool(entity.get("construction")), "raw": entity, } if "line" in entity_type: return { "type": "line", "start": _sketch_point_mm(entity, "start"), "end": _sketch_point_mm(entity, "end"), "construction": bool(entity.get("construction")), "raw": entity, } if "circle" in entity_type: return { "type": "circle", "center": _sketch_point_mm(entity, "center"), "radius_mm": _sketch_radius_mm(entity), "construction": bool(entity.get("construction")), "raw": entity, } if "arc" in entity_type: return { "type": "arc", "center": _sketch_point_mm(entity, "center"), "start": _sketch_point_mm(entity, "start"), "end": _sketch_point_mm(entity, "end"), "radius_mm": _sketch_radius_mm(entity), "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), "construction": bool(entity.get("construction")), "raw": entity, } if entity_type == "point": point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} return None def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: point = entity.get(f"{key}_mm") if isinstance(point, list) and len(point) >= 2: return [float(point[0]), float(point[1])] return _scale_point(entity.get(key, [0, 0])) def _sketch_radius_mm(entity: Dict[str, Any]) -> float: for key in ("radius_mm", "major_radius_mm", "major_radius"): if entity.get(key) is not None: return _scale_length(entity.get(key)) if entity.get("radius") is not None: return _scale_length(entity.get("radius")) start = _sketch_point_mm(entity, "start") center = _sketch_point_mm(entity, "center") if start and center: return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) return 1.0 def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: data_block = feature.get("extrude_data", {}) op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" distance = _best_extrude_depth_mm(feature, data_block) reverse_end_condition_code = data_block.get("reverse_end_condition_code") reverse_distance = abs(data_block.get("reverse_depth") or 0) both_directions = bool(data_block.get("both_directions", False)) reverse_direction = data_block.get("is_reverse") if reverse_direction is None: reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") if reverse_direction is None: reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) if spans_both_sides and bool(reverse_direction): both_directions = True reverse_distance = reverse_distance or distance else: both_directions = False reverse_distance = 0 raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) uses_reverse_depth_only = ( op_type == "extrude_cut" and feature.get("type") == "ice" and raw_depth <= 1e-9 and reverse_distance > 0 ) if uses_reverse_depth_only: reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) return { "id": feature.get("id"), "name": feature.get("name"), "type": op_type, "sketch": sketch_id, "parameters": { "distance_mm": distance, "reverse": bool(reverse_direction), "reverse_direction": bool(reverse_direction), "reverse_distance_mm": reverse_distance, "both_directions": False if uses_reverse_depth_only else both_directions, "end_condition": data_block.get("end_condition"), "end_condition_code": data_block.get("end_condition_code"), "reverse_end_condition_code": reverse_end_condition_code, "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), "draft_angle_rad": data_block.get("draft_angle_rad"), "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), }, "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), } def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: sketches = data_block.get("source_sketches") or [] workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None if not isinstance(workplane, dict): return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) origin = workplane.get("origin_mm") or [0, 0, 0] normal = workplane.get("normal") or [0, 0, 1] if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: return False nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 nx, ny, nz = nx / length, ny / length, nz / length ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) min_distance = math.inf max_distance = -math.inf for face in feature.get("owned_faces") or []: box = face.get("box_m") if isinstance(face, dict) else None if not isinstance(box, list) or len(box) < 6: continue xs = [float(box[0]) * 1000, float(box[3]) * 1000] ys = [float(box[1]) * 1000, float(box[4]) * 1000] zs = [float(box[2]) * 1000, float(box[5]) * 1000] for x in xs: for y in ys: for z in zs: distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz min_distance = min(min_distance, distance_to_plane) max_distance = max(max_distance, distance_to_plane) if math.isinf(min_distance) or math.isinf(max_distance): return False tolerance = 1e-4 return min_distance < -tolerance and max_distance > tolerance def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: data_block = feature.get("revolve_data", {}) op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) owned_face_axis = _axis_reference_from_owned_faces(feature) extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) axis_reference = selected_axis or owned_face_axis if not axis_reference and not _is_weak_inferred_axis(extracted_axis): axis_reference = extracted_axis return { "id": feature.get("id"), "name": feature.get("name"), "type": op_type, "sketch": sketch_id, "parameters": { "angle_deg": abs(data_block.get("angle") or 360), "angle_rad": data_block.get("angle_rad"), "reverse": data_block.get("is_reverse", False), "end_condition": data_block.get("end_condition"), "end_condition_code": data_block.get("end_condition_code"), "axis_reference": axis_reference, "axis_candidates": data_block.get("axis_candidates", []), }, "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), } def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: candidates: list[tuple[float, Dict[str, Any]]] = [] for face in feature.get("owned_faces") or []: if not isinstance(face, dict): continue surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} params = None if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): params = surface.get("cylinder_params") elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): params = surface.get("cone_params") if not isinstance(params, list) or len(params) < 6: continue direction = [float(value) for value in params[3:6]] norm = math.sqrt(sum(value * value for value in direction)) if norm <= 1e-9: continue candidates.append(( float(face.get("area_m2") or 0.0), { "origin_mm": [float(value) * 1000 for value in params[:3]], "direction": [value / norm for value in direction], "source": "owned_face_axis", }, )) if not candidates: return None candidates.sort(key=lambda item: item[0], reverse=True) return candidates[0][1] def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: if not isinstance(axis_reference, dict): return False return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: data_block = feature.get("hole_data", {}) positions = [] host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) for sketch in position_sketches: workplane = sketch.get("workplane") or {} if not host_face and workplane: host_face = _host_face_from_workplane(workplane) for point in _hole_position_points(sketch): positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) return { "id": feature.get("id"), "name": feature.get("name"), "type": "hole", "parameters": { "diameter_mm": diameter_mm, "depth_mm": depth_mm, "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) or _hole_dimension_value(data_block, ("锥坑", "直径")) or _hole_dimension_value(data_block, ("countersink", "diameter")) or _hole_dimension_value(data_block, ("csk", "diameter")), "angles_rad": { "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) or _hole_angle_dimension_rad(data_block, ("csk", "angle")), "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) or _hole_angle_dimension_rad(data_block, ("drill", "angle")) or _hole_angle_dimension_rad(data_block, ("tip", "angle")), }, "positions": positions, "host_face": host_face, "hole_type": data_block.get("hole_type"), "standard": data_block.get("standard"), "size": data_block.get("size"), "dimension_names": [ str(dim.get("name") or "") for dim in data_block.get("dimensions", []) or [] if isinstance(dim, dict) ], }, "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), } def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: point_only = [] for sketch in sketches: entities = sketch.get("entities") or [] if not entities: continue if _is_hole_profile_sketch(sketch): continue point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) drawable_segment_count = sum( 1 for entity in entities if not _is_sketch_point_entity(entity) and not entity.get("construction") ) if point_count > 0 and drawable_segment_count == 0: point_only.append(sketch) return point_only or sketches[:1] def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: tokens = ( "孔直径", "孔深度", "沉头", "导头", "螺纹孔钻头", "tap drill", "drill", "counterbore", "countersink", "hole diameter", "hole depth", ) dimension_sources = [] dimension_sources.extend(sketch.get("dimensions") or []) dimension_sources.extend(sketch.get("feature_dimensions") or []) for dim in dimension_sources: if not isinstance(dim, dict): continue name = str(dim.get("name") or "").lower() if any(token in name for token in tokens): return True return False def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() return entity_type == "point" def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity candidate = raw.get("hole_position_candidate") if candidate is None: candidate = entity.get("hole_position_candidate") if isinstance(candidate, bool): candidate_flag: Optional[bool] = candidate else: candidate_flag = None construction_reference = bool( raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") ) return candidate_flag, construction_reference def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: degrees: dict[tuple[float, float, float], int] = {} for entity in sketch.get("entities") or []: if not entity.get("construction"): continue entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() if "line" not in entity_type: continue for key in ("start_mm", "end_mm"): endpoint = entity.get(key) if isinstance(endpoint, list) and len(endpoint) >= 2: point_key = _rounded_point_key(endpoint) degrees[point_key] = degrees.get(point_key, 0) + 1 return degrees def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: """Return only real Hole Wizard placement points from a position sketch. SolidWorks Hole Wizard position sketches often include construction segments whose endpoints are reference geometry, not hole centers. Older parser JSON exposes those endpoints as ordinary sketch points, so we filter them generically here instead of letting every point become a hole. """ entities = sketch.get("entities") or [] point_entities: list[tuple[list[float], Optional[bool], bool]] = [] construction_endpoints: set[tuple[float, float, float]] = set() for entity in entities: point = entity.get("point_mm") if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: candidate_flag, construction_reference = _hole_position_entity_flags(entity) point_entities.append( ( [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], candidate_flag, construction_reference, ) ) continue if not entity.get("construction"): continue entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() if "line" not in entity_type: continue for key in ("start_mm", "end_mm"): endpoint = entity.get(key) if isinstance(endpoint, list) and len(endpoint) >= 2: construction_endpoints.add(_rounded_point_key(endpoint)) if not point_entities: return [] explicit_candidates = [ point for point, candidate_flag, _ in point_entities if candidate_flag is True ] if explicit_candidates: return _dedupe_points(explicit_candidates) endpoint_degrees = _construction_endpoint_degrees(sketch) if endpoint_degrees: filtered = [] for point, candidate_flag, construction_reference in point_entities: point_key = _rounded_point_key(point) degree = endpoint_degrees.get(point_key, 0) if candidate_flag is False and construction_reference and degree <= 1: continue if degree >= 2 or not construction_reference: filtered.append(point) filtered = _dedupe_points(filtered) non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] if non_origin_filtered: return _dedupe_points(non_origin_filtered) if filtered: return filtered raw_points = _dedupe_points([point for point, _, _ in point_entities]) if not raw_points or not construction_endpoints: return raw_points legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] if non_origin_filtered: return _dedupe_points(non_origin_filtered) if non_origin_raw: return _dedupe_points(non_origin_raw) return _dedupe_points(legacy_filtered or raw_points) def _dedupe_points(points: list[list[float]]) -> list[list[float]]: result = [] seen = set() for point in points: key = _rounded_point_key(point) if key in seen: continue seen.add(key) result.append(point) return result def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: z = point[2] if len(point) > 2 else 0 return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) def _convert_sw_linear_pattern( feature: Dict[str, Any], index: int, previous_build_op: Optional[Dict[str, Any]], source_frame: Optional[Dict[str, Any]] = None, sketches: Optional[list[Dict[str, Any]]] = None, source_bbox: Optional[list[float]] = None, ) -> Dict[str, Any]: data_block = feature.get("linear_pattern_data", {}) source_features = data_block.get("source_features") or [] if not source_features and previous_build_op: source_features = [previous_build_op.get("source_feature", {})] spacing_1 = data_block.get("spacing_1") spacing_2 = data_block.get("spacing_2") direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) if data_block.get("direction_1_reverse") is True: direction_1 = _reverse_pattern_direction(direction_1) if data_block.get("direction_2_reverse") is True: direction_2 = _reverse_pattern_direction(direction_2) source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) if data_block.get("direction_1") is None: direction_1 = _choose_pattern_direction_sign( direction_1, spacing_1 or 0, int(data_block.get("pattern_count_1") or 1), source_op_bbox, source_bbox, ) if data_block.get("direction_2") is None: direction_2 = _choose_pattern_direction_sign( direction_2, spacing_2 or 0, int(data_block.get("pattern_count_2") or 1), source_op_bbox, source_bbox, ) explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) return { "id": feature.get("id"), "name": feature.get("name"), "type": "linear_pattern", "parameters": { "source_features": source_features, "total_instances": data_block.get("pattern_count_1") or 1, "spacing_mm": spacing_1 or 0, "direction1": direction_1, "direction2": direction_2, }, "raw_parameters": { "d1_total_instances": data_block.get("pattern_count_1") or 1, "d2_total_instances": data_block.get("pattern_count_2") or 1, "d1_spacing_mm": spacing_1 or 0, "d2_spacing_mm": spacing_2 or 0, "direction1": direction_1, "direction2": direction_2, "explicit_offsets_mm": explicit_offsets, }, "source_feature": _source_feature(feature, index), "source_owned_faces": _source_owned_faces(feature), } def _owned_face_pattern_offsets( source_op: Optional[Dict[str, Any]], pattern_feature: Dict[str, Any], ) -> list[list[float]]: if not source_op: return [] source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) if not source_faces or not pattern_faces: return [] votes: Dict[tuple[float, float, float], int] = {} for pattern_face in pattern_faces: for source_face in source_faces: if pattern_face["kind"] != source_face["kind"]: continue if not _similar_bbox_size(pattern_face["size"], source_face["size"]): continue offset = tuple( round(pattern_face["center"][axis] - source_face["center"][axis], 3) for axis in range(3) ) if math.sqrt(sum(component * component for component in offset)) < 1e-6: continue votes[offset] = votes.get(offset, 0) + 1 if not votes: return [] threshold = max(1, min(2, len(source_faces))) offsets = [offset for offset, count in votes.items() if count >= threshold] offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) return [[float(value) for value in offset] for offset in offsets] def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: signatures = [] for face in faces: if not isinstance(face, dict): continue box = face.get("box_m") if not isinstance(box, list) or len(box) < 6: continue box_mm = [float(value) * 1000 for value in box[:6]] surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} kind = "other" if surface.get("is_cylinder"): kind = "cylinder" elif surface.get("is_cone"): kind = "cone" elif surface.get("is_plane"): kind = "plane" signatures.append( { "kind": kind, "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], } ) return signatures def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) def _source_pattern_frame( previous_build_op: Optional[Dict[str, Any]], sketches: list[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: if not previous_build_op: return None params = previous_build_op.get("parameters") or {} host_frame = ((params.get("host_face") or {}).get("frame") or {}) if host_frame.get("x_dir") and host_frame.get("y_dir"): return host_frame sketch_id = previous_build_op.get("sketch") for sketch in sketches: if sketch.get("id") == sketch_id: workplane = sketch.get("workplane") or {} if workplane.get("x_dir") and workplane.get("y_dir"): return workplane return None def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: bbox = (data.get("validation_hints") or {}).get("part_box_m") if isinstance(bbox, list) and len(bbox) >= 6: return [float(v) * 1000 for v in bbox[:6]] return None def _operation_profile_bbox( op: Optional[Dict[str, Any]], sketches: list[Dict[str, Any]], ) -> Optional[list[float]]: if not op: return None if op.get("type") == "hole": host_face = (op.get("parameters") or {}).get("host_face") or {} positions = [ _hole_position_to_model(pos.get("mm"), host_face) for pos in (op.get("parameters") or {}).get("positions", []) if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 ] if positions: return _points_bbox(positions) sketch_id = op.get("sketch") sketch = next((item for item in sketches if item.get("id") == sketch_id), None) if not sketch: return None points = [] workplane = sketch.get("workplane") or {} origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] y_dir = workplane.get("y_dir") or [0, 1, 0] for entity in sketch.get("entities", []) or []: if entity.get("type") == "circle": center = entity.get("center") or [0, 0] radius = float(entity.get("radius_mm") or 0) for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) for key in ("start", "end", "center", "point"): point = entity.get(key) if isinstance(point, list) and len(point) >= 2: points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) return _points_bbox(points) def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: return [ float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) for i in range(3) ] def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: if not points: return None return [ min(point[0] for point in points), min(point[1] for point in points), min(point[2] for point in points), max(point[0] for point in points), max(point[1] for point in points), max(point[2] for point in points), ] def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: frame = host_face.get("frame") if isinstance(host_face, dict) else {} if not isinstance(frame, dict): return [float(v) for v in (point + [0, 0, 0])[:3]] origin = frame.get("origin_mm") or [0, 0, 0] x_dir = frame.get("x_dir") or [1, 0, 0] y_dir = frame.get("y_dir") or [0, 1, 0] values = [float(v) for v in (point + [0, 0, 0])[:3]] return [ float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] for i in range(3) ] def _choose_pattern_direction_sign( direction: Dict[str, Any], spacing: float, count: int, source_op_bbox: Optional[list[float]], source_bbox: Optional[list[float]], ) -> Dict[str, Any]: vector = direction.get("vector") if ( not isinstance(vector, list) or len(vector) < 3 or not spacing or count <= 1 or not source_op_bbox or not source_bbox ): return direction unit = _unit3(vector) distance = float(spacing) * (count - 1) positive = [component * distance for component in unit] negative = [-component * distance for component in unit] positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) if abs(positive_score - negative_score) <= 1e-9: positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) copied = dict(direction) if negative_score + 1e-9 < positive_score: copied["vector"] = [-component for component in unit] copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" return copied copied["vector"] = unit if positive_score + 1e-9 < negative_score: copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" return copied def _unit3(vector: list[Any]) -> list[float]: raw = [float(vector[i]) for i in range(3)] length = math.sqrt(sum(v * v for v in raw)) if length <= 0: return [0.0, 0.0, 0.0] return [v / length for v in raw] def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: return [ bbox[0] + offset[0], bbox[1] + offset[1], bbox[2] + offset[2], bbox[3] + offset[0], bbox[4] + offset[1], bbox[5] + offset[2], ] def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: score = 0.0 for axis in range(3): score += max(source[axis] - candidate[axis], 0) score += max(candidate[axis + 3] - source[axis + 3], 0) return score def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: score = 0.0 for axis in range(3): source_center = (source[axis] + source[axis + 3]) / 2 candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 axis_size = max(source[axis + 3] - source[axis], 1.0) score += abs(candidate_center - source_center) / axis_size return score def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: text = f"{type_name} {feature.get('name', '')}".lower() return "cut" in text or "切除" in text or "revcut" in text def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: for key in ("depth", "blind_depth"): value = data_block.get(key) if value: return abs(float(value)) owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) effective_depth = abs(float(data_block.get("effective_depth") or 0)) if ( owned_face_depth and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) and data_block.get("effective_depth_source") == "feature_dimension" and not data_block.get("depth") and not data_block.get("blind_depth") and not data_block.get("reverse_depth") and effective_depth > owned_face_depth * 2 ): return owned_face_depth owner_name = feature.get("name") for dim in data_block.get("dimensions", []) or []: name = dim.get("name") or "" if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): return abs(float(dim.get("value"))) for dim in data_block.get("dimensions", []) or []: if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): return abs(float(dim.get("value"))) if data_block.get("reverse_depth") not in (None, 0): return abs(float(data_block.get("reverse_depth"))) if data_block.get("effective_depth") not in (None, 0): return abs(float(data_block.get("effective_depth"))) return 0.0 def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: sketches = data_block.get("source_sketches") or [] workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None if not isinstance(workplane, dict): return None normal = workplane.get("normal") or [0, 0, 1] if not isinstance(normal, list) or len(normal) < 3: return None axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) values: list[float] = [] for face in feature.get("owned_faces") or []: if not isinstance(face, dict): continue box = face.get("box_m") if isinstance(box, list) and len(box) >= 6: values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) if not values: return None extent = max(values) - min(values) return abs(extent) if extent > 1e-6 else None def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: origin = workplane.get("origin_mm") or [0, 0, 0] normal = workplane.get("normal") or [0, 0, 1] x_dir = workplane.get("x_dir") or [1, 0, 0] y_dir = workplane.get("y_dir") or [0, 1, 0] return { "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, } def _pattern_direction_from_plugin( direction: Any, axis: str, source_frame: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: if isinstance(direction, dict): return direction if source_frame: key = "y_dir" if axis == "y" else "x_dir" vector = source_frame.get(key) if isinstance(vector, list) and len(vector) >= 3: return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} if axis == "y": return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} def _pattern_direction_from_reference( fallback: Dict[str, Any], reference: Any, source_frame: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: axis = _extract_axis_reference(reference) if not axis: return fallback vector = axis.get("direction") if not isinstance(vector, list) or len(vector) < 3: return fallback model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") return { "vector": _unit3(model_vector), "origin_mm": model_origin, "source": axis.get("source") or "direction_reference", } def _sketch_vector_to_model( vector: list[Any], source_frame: Optional[Dict[str, Any]], ) -> Optional[list[float]]: if not source_frame: return None x_dir = source_frame.get("x_dir") y_dir = source_frame.get("y_dir") normal = source_frame.get("normal") if not ( isinstance(x_dir, list) and len(x_dir) >= 3 and isinstance(y_dir, list) and len(y_dir) >= 3 ): return None if not (isinstance(normal, list) and len(normal) >= 3): normal = [ float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), ] values = [float(v) for v in (vector + [0, 0, 0])[:3]] return [ values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) for i in range(3) ] def _sketch_point_to_model( point: Any, source_frame: Optional[Dict[str, Any]], ) -> Optional[list[float]]: if not isinstance(point, list) or len(point) < 3 or not source_frame: return None origin = source_frame.get("origin_mm") vector = _sketch_vector_to_model(point[:3], source_frame) if not (isinstance(origin, list) and len(origin) >= 3 and vector): return None return [float(origin[i]) + vector[i] for i in range(3)] def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: vector = direction.get("vector") if not isinstance(vector, list) or len(vector) < 3: return direction copied = dict(direction) copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] copied["source"] = f"{direction.get('source', 'direction')}_reversed" return copied def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: if not isinstance(reference, dict): return None if reference.get("kind") == "null": return None obj = reference.get("object") if isinstance(obj, dict) and obj.get("kind") == "null": return None return reference def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: if not isinstance(reference, dict): return None if reference.get("origin_mm") and reference.get("direction"): return { "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], "direction": [float(v) for v in reference.get("direction", [])[:3]], "source": reference.get("source") or "axis_reference", } obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference if obj.get("kind") == "null": return None line_params = obj.get("line_params") if isinstance(line_params, list) and len(line_params) >= 6: return { "origin_mm": [float(v) * 1000 for v in line_params[:3]], "direction": [float(v) for v in line_params[3:6]], "source": reference.get("source") or "selection_line_params", } curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} curve_line_params = curve.get("line_params") if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: return { "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], "direction": [float(v) for v in curve_line_params[3:6]], "source": reference.get("source") or "selection_curve_line_params", } return None def _selection_objects(selections: Any) -> list[Dict[str, Any]]: objects: list[Dict[str, Any]] = [] if not isinstance(selections, list): return objects for selection in selections: if not isinstance(selection, dict): continue obj = selection.get("object") if isinstance(obj, dict) and obj.get("kind") != "null": objects.append(obj) return objects def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: for obj in _selection_objects(selections): axis = _extract_axis_reference(obj) if axis: axis["source"] = "feature_selection_axis" return axis return None def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: for obj in _selection_objects(selections): if obj.get("kind") != "face": continue surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} if not frame: continue normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] origin = frame.get("origin_mm") if not origin: plane_params = surface.get("plane_params") if isinstance(plane_params, list) and len(plane_params) >= 6: origin = [float(v) * 1000 for v in plane_params[3:6]] if not origin: origin = [0, 0, 0] x_dir = frame.get("x_dir") or [1, 0, 0] y_dir = frame.get("y_dir") or [0, 1, 0] origin_values = list(origin) x_values = list(x_dir) y_values = list(y_dir) normal_values = list(normal) return { "surface": surface, "frame": { "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], }, "source": "feature_selection_face", } return None def _tuple3(values: Any) -> tuple[float, float, float]: values = list(values or [0, 0, 0]) values = (values + [0, 0, 0])[:3] return tuple(values) def _point_m_to_mm(point: Any) -> tuple[float, float, float]: values = list(point or [0, 0, 0]) values = (values + [0, 0, 0])[:3] return tuple(float(value) * 1000 for value in values) def _scale_point(point: Any) -> list[float]: values = [0 if value is None else float(value) for value in (point or [0, 0])] return [_scale_length(value) for value in values[:2]] def _scale_length(value: Any) -> float: value = 0 if value is None else float(value) return value * 1000 if abs(value) <= 10 else value def _to_degrees(value: Any) -> float: value = 0 if value is None else float(value) return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value