Files
cdsl-cad/backend/engine/cdsl_engine/translator/codegen.py
T
ganjihong ad88d92ab9 refactor(cdsl_engine): split translator.py into the translator package
Phase 5 of the decoupling refactor (behavior-preserving move):
- translator/ir.py: SolidWorks plugin JSON to backend-IR conversion (70 syms)
- translator/codegen.py: backend IR to build123d source generation (64 syms)
- translator/runtime_lib.py: frozen generated-script runtime library,
  spliced into generate_build123d_code as *RUNTIME_LIB_LINES
- translator/common.py: helpers shared by both sides
- translator/__init__.py: full historical symbol surface re-exported

Generated-code equivalence verified byte-for-byte against the pre-split
output for a representative IR sample; py_compile clean.
2026-09-09 13:56:08 +08:00

2017 lines
91 KiB
Python

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