From ad88d92ab904410706d1edf3f4a55d2f807013ca Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:56:08 +0800 Subject: [PATCH] refactor(cdsl_engine): split translator.py into the translator package Phase 5 of the decoupling refactor (behavior-preserving move): - translator/ir.py: SolidWorks plugin JSON to backend-IR conversion (70 syms) - translator/codegen.py: backend IR to build123d source generation (64 syms) - translator/runtime_lib.py: frozen generated-script runtime library, spliced into generate_build123d_code as *RUNTIME_LIB_LINES - translator/common.py: helpers shared by both sides - translator/__init__.py: full historical symbol surface re-exported Generated-code equivalence verified byte-for-byte against the pre-split output for a representative IR sample; py_compile clean. --- backend/engine/cdsl_engine/translator.py | 5184 ----------------- .../engine/cdsl_engine/translator/__init__.py | 164 + .../engine/cdsl_engine/translator/codegen.py | 2016 +++++++ .../engine/cdsl_engine/translator/common.py | 194 + backend/engine/cdsl_engine/translator/ir.py | 1959 +++++++ .../cdsl_engine/translator/runtime_lib.py | 949 +++ 6 files changed, 5282 insertions(+), 5184 deletions(-) delete mode 100644 backend/engine/cdsl_engine/translator.py create mode 100644 backend/engine/cdsl_engine/translator/__init__.py create mode 100644 backend/engine/cdsl_engine/translator/codegen.py create mode 100644 backend/engine/cdsl_engine/translator/common.py create mode 100644 backend/engine/cdsl_engine/translator/ir.py create mode 100644 backend/engine/cdsl_engine/translator/runtime_lib.py diff --git a/backend/engine/cdsl_engine/translator.py b/backend/engine/cdsl_engine/translator.py deleted file mode 100644 index 74a7439d..00000000 --- a/backend/engine/cdsl_engine/translator.py +++ /dev/null @@ -1,5184 +0,0 @@ -"""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 diff --git a/backend/engine/cdsl_engine/translator/__init__.py b/backend/engine/cdsl_engine/translator/__init__.py new file mode 100644 index 00000000..8387e37c --- /dev/null +++ b/backend/engine/cdsl_engine/translator/__init__.py @@ -0,0 +1,164 @@ +"""Compatibility package for the historical ``cdsl_engine.translator`` module. + +The former single-file translator now lives in ``ir`` (SolidWorks plugin JSON +to backend IR), ``codegen`` (backend IR to build123d source), ``runtime_lib`` +(frozen generated-script library), and ``common`` (shared helpers). Every +historical import path and symbol keeps working. +""" + +from __future__ import annotations + +from .common import SW_END_CONDITIONS, THROUGH_CUT_AMOUNT_MM +from .ir import ( + _SW_METADATA_FEATURE_TYPES, + _append_feature_source_sketches, + _axis_reference_from_feature_selections, + _axis_reference_from_owned_faces, + _best_extrude_depth_mm, + _choose_pattern_direction_sign, + _clean_null_reference, + _construction_endpoint_degrees, + _contour_entity_indices_from_segments, + _convert_sw_assembly, + _convert_sw_extrude, + _convert_sw_hole, + _convert_sw_imported_body, + _convert_sw_linear_pattern, + _convert_sw_reference, + _convert_sw_revolve, + _convert_sw_sketch, + _convert_sw_sketch_entity, + _editable_param, + _extract_axis_reference, + _extract_edge_selector_points, + _extract_sketch_parameters, + _extrude_depth_from_owned_faces, + _extrude_owned_faces_span_sketch_plane, + _feature_length_dimension_mm, + _feature_selection_selectors, + _feature_selection_source, + _hole_angle_dimension_rad, + _hole_counterbore_depth_dimension_mm, + _hole_counterbore_dimension_mm, + _hole_dimension_value, + _hole_dimension_value_excluding, + _hole_position_entity_flags, + _hole_position_points, + _hole_position_sketches, + _hole_position_to_model, + _hole_primary_depth_mm, + _hole_primary_diameter_mm, + _hole_primary_dimension_fallback, + _host_face_from_feature_selections, + _host_face_from_workplane, + _is_cut_feature, + _is_hole_profile_sketch, + _is_imported_body_feature, + _is_sketch_point_entity, + _is_weak_inferred_axis, + _operation_profile_bbox, + _owned_face_pattern_offsets, + _owned_face_signatures, + _pattern_direction_from_plugin, + _pattern_direction_from_reference, + _reverse_pattern_direction, + _selectable_stable_ids, + _selection_objects, + _selector_has_persistent_reference, + _sketch_bounds, + _sketch_point_mm, + _sketch_point_to_model, + _sketch_point_to_model_bbox, + _sketch_radius_mm, + _sketch_vector_to_model, + _source_bbox_from_plugin_json, + _source_feature, + _source_owned_faces, + _source_pattern_frame, + analyze_parameterization_status, + convert_sw_plugin_json_to_ir, + enrich_rebuild_parameters, + extract_editable_parameters, + normalize_to_ir, +) +from .codegen import ( + _active_profile_loops, + _aligned_workplane_for_owned_midplane, + _blind_extrude_face_offset, + _effective_extrude_cut_depth_mm, + _effective_hole_cut_depth_mm, + _extract_mirror_plane_normal, + _extract_mirror_plane_origin, + _find_operation_for_source_feature, + _find_source_operation_for_pattern, + _flip_side_step_inner_radius_mm, + _flip_side_uses_step_ring, + _generate_assembly_compose, + _generate_chamfer, + _generate_extrude, + _generate_fillet, + _generate_hole, + _generate_imported_body_pending, + _generate_linear_pattern, + _generate_mirror_pattern, + _generate_move_face, + _generate_revolve, + _generate_sketch, + _hole_counterbore_depth_mm, + _hole_counterbore_diameter_mm, + _hole_countersink_angle_rad, + _hole_countersink_diameter_mm, + _hole_depth_mm, + _hole_diameter_mm, + _hole_drill_angle_rad, + _hole_has_drill_tip, + _hole_has_through_dimension, + _hole_owned_cut_faces, + _hole_should_use_sw_cut_holes, + _infer_closed_wire_loops, + _linear_pattern_offsets, + _looks_like_reverse_history, + _loop_area_from_radii, + _loop_radius_candidates, + _loops_matching_owned_radii, + _model_offset_to_host_local, + _operation_priority, + _ordered_wire_entities, + _owned_bbox_cut, + _owned_cylindrical_cut_faces, + _owned_extrude_terminal_offsets_mm, + _owned_profile_radii_mm, + _pattern_direction_vector, + _prefer_blind_sketch_extrude, + _project_owned_faces_to_sketch_bbox, + _resolve_extrude_owned_termination, + _reverse_curve_entity, + _revolve_axis_expr, + _sketch_circle_radii_mm, + _sketch_construction_axis, + _sketch_has_buildable_profile, + _sketch_point_to_model_from_basis, + _sw_math_transform_matrix, + _translate_owned_faces, + _translate_sketch_entities, + _translated_operation, + _translated_sketch, + generate_build123d_code, + get_part_name, + sort_operations_for_history, +) +from .runtime_lib import RUNTIME_LIB_LINES + +__all__ = [ + "RUNTIME_LIB_LINES", + "SW_END_CONDITIONS", + "THROUGH_CUT_AMOUNT_MM", + "analyze_parameterization_status", + "convert_sw_plugin_json_to_ir", + "enrich_rebuild_parameters", + "extract_editable_parameters", + "generate_build123d_code", + "get_part_name", + "normalize_to_ir", + "sort_operations_for_history", +] diff --git a/backend/engine/cdsl_engine/translator/codegen.py b/backend/engine/cdsl_engine/translator/codegen.py new file mode 100644 index 00000000..9ba37f84 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/codegen.py @@ -0,0 +1,2016 @@ +"""Backend-IR to build123d source-code generation.""" + +from __future__ import annotations + +import json +import math +import os +import re +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + _tuple3, + _point_key, + _bbox_area_2d, + _bbox_contains_2d, + _bbox_overlap_ratio_2d, + _loop_bbox, + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, +) +from .runtime_lib import RUNTIME_LIB_LINES + + +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}", + *RUNTIME_LIB_LINES, + ] + + 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 _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_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")) diff --git a/backend/engine/cdsl_engine/translator/common.py b/backend/engine/cdsl_engine/translator/common.py new file mode 100644 index 00000000..728e9827 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/common.py @@ -0,0 +1,194 @@ +"""Shared helpers for the SW-IR and code-generation sides of the translator. + +These small utilities are used by both ``ir`` (SolidWorks plugin JSON to +backend IR) and ``codegen`` (backend IR to build123d source). Anything used +by exactly one side lives in that side's module instead. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +#: SolidWorks numeric end-condition codes, shared by IR conversion and codegen. +SW_END_CONDITIONS = { + 0: "Blind", + 1: "ThroughAll", + 2: "ThroughAllBoth", + 3: "UpToVertex", + 4: "UpToSurface", + 5: "OffsetFromSurface", + 6: "ThroughAllAndBlind", + 7: "UpToBody", + 8: "MidPlane", + 9: "ThroughNext", +} + +#: Generous through-cut length used when a termination reference is missing. +THROUGH_CUT_AMOUNT_MM = 200 + + +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 + + +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 _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 _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 _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 _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 _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 _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 _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_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), + ] diff --git a/backend/engine/cdsl_engine/translator/ir.py b/backend/engine/cdsl_engine/translator/ir.py new file mode 100644 index 00000000..6517d727 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/ir.py @@ -0,0 +1,1959 @@ +"""SolidWorks plugin JSON to backend-IR conversion.""" + +from __future__ import annotations + +import json +import math +import os +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, + _point_m_to_mm, + _scale_point, + _scale_length, + _to_degrees, + _unit3, + _points_bbox, + _rounded_point_key, + _dedupe_points, + _is_near_origin, + _similar_bbox_size, + _translated_bbox, + _bbox_overflow_score, + _bbox_center_distance_score, + _bbox_area_2d, + _loop_bbox, +) + + +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 _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 _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 _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 _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 _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 diff --git a/backend/engine/cdsl_engine/translator/runtime_lib.py b/backend/engine/cdsl_engine/translator/runtime_lib.py new file mode 100644 index 00000000..06497db7 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/runtime_lib.py @@ -0,0 +1,949 @@ +"""Frozen runtime library embedded into generated build123d scripts. + +These lines are appended after the per-model header (imports plus source +volume/area constants) in every generated script. The library is stable: +safe boolean wrappers, selector-based edge matching, and owned-face cutters. +Changes here affect every translator-generated rebuild. +""" + +from __future__ import annotations + +RUNTIME_LIB_LINES: list[str] = [ + "", + "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", + "", +]