ad88d92ab9
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.
2017 lines
91 KiB
Python
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"))
|