"""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