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