Files
cdsl-cad/backend/engine/cdsl_engine/translator.py
T
2026-08-19 19:34:30 +08:00

5185 lines
231 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generic SolidWorks JSON/IR to build123d code translator."""
from __future__ import annotations
import json
import math
import os
import re
from copy import deepcopy
from typing import Any, Dict, Optional
SW_END_CONDITIONS = {
0: "Blind",
1: "ThroughAll",
2: "ThroughAllBoth",
3: "UpToVertex",
4: "UpToSurface",
5: "OffsetFromSurface",
6: "ThroughAllAndBlind",
7: "UpToBody",
8: "MidPlane",
9: "ThroughNext",
}
THROUGH_CUT_AMOUNT_MM = 200
def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize supported input formats to the backend internal IR."""
if "operations" in data and "sketches" in data:
return enrich_rebuild_parameters(data)
if "features" in data:
return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data))
raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON")
def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]:
"""Add a generic editable-parameter index without changing feature history.
The returned rebuild JSON remains the source of truth for execution. The
`editable_parameters` section is an index of JSON paths that a UI or caller
can modify safely while preserving the original feature order and links.
"""
enriched = dict(data)
enriched["editable_parameters"] = extract_editable_parameters(enriched)
enriched["parameterization_status"] = analyze_parameterization_status(enriched)
return enriched
def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]:
issues = []
for sketch in data.get("sketches", []):
host_reference = sketch.get("host_reference", {})
reference = host_reference.get("reference") or {}
if reference.get("kind") == "face" and not reference.get("owner_feature"):
issues.append({
"kind": "missing_stable_face_owner",
"sketch": {"id": sketch.get("id"), "name": sketch.get("name")},
"message": (
"Sketch is attached to a face geometry, but the JSON does not identify "
"the owning feature/face id. Parameter edits may require updating this "
"sketch workplane manually unless the plugin exports stable face ownership."
),
})
for op in data.get("operations", []):
if op.get("type") in ("unsupported", "unknown"):
sw_type = op.get("parameters", {}).get("sw_type") or op.get("type")
issues.append({
"kind": "unsupported_geometry_feature",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type},
"message": (
f"SolidWorks feature '{sw_type}' is present in the history, but the "
"core build123d translator has no generic implementation for it. "
"The feature is retained in IR and must not be treated as a complete rebuild."
),
})
if op.get("type") == "hole":
host_face = op.get("parameters", {}).get("host_face") or {}
if host_face and not host_face.get("frame"):
issues.append({
"kind": "missing_hole_host_frame",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"message": (
"Hole feature has a host face, but the JSON does not include the "
"face-local x/y axes. The translator can infer common axis-aligned "
"cases, but the plugin should export the sketch/face frame for exact "
"generic hole placement."
),
})
if op.get("type") == "extrude_cut":
end_code = op.get("parameters", {}).get("end_condition_code")
if end_code in (3, 4, 5, 7, 9):
params = op.get("parameters", {})
has_termination_reference = any(
params.get(key)
for key in (
"end_condition_reference",
"reverse_end_condition_reference",
"termination_reference",
)
)
kind = (
"sw_end_condition_requires_exact_translator"
if has_termination_reference
else "missing_extrude_termination_reference"
)
issues.append({
"kind": kind,
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"end_condition_code": end_code,
"end_condition": SW_END_CONDITIONS.get(end_code),
"message": (
"This SW cut uses a non-blind end condition. ThroughAll can be "
"replayed generically, but ThroughNext/UpTo-style rebuilds need the "
"selected terminating face/body/reference from the plugin for exact 1:1."
),
})
if op.get("type") in ("revolve_cut", "revolve_add"):
axis_reference = op.get("parameters", {}).get("axis_reference")
if not axis_reference or not (
isinstance(axis_reference, dict)
and axis_reference.get("origin_mm")
and axis_reference.get("direction")
):
axis_candidates = op.get("parameters", {}).get("axis_candidates") or []
if axis_candidates:
issues.append({
"kind": "revolve_axis_inferred_from_candidate",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"message": (
"Revolve feature lacks the original SolidWorks selected axis, but "
"the translator can use a construction-line candidate. For exact "
"auditability the plugin should still export the selected axis "
"reference and selection mark."
),
})
continue
issues.append({
"kind": "missing_revolve_axis_reference",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"message": (
"Revolve feature does not include the SolidWorks selected axis. "
"The translator can only infer an axis from the sketch workplane, "
"which is not reliable enough for exact 1:1 rebuild."
),
})
if op.get("type") in ("linear_pattern", "pattern_linear"):
params = op.get("parameters", {})
if not params.get("source_features") or not _linear_pattern_offsets(op):
issues.append({
"kind": "linear_pattern_missing_source_or_direction",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"message": (
"This SW linear pattern lacks source-feature selection or direction data. "
"The translator can replay patterns when source features and offsets are "
"available; otherwise the plugin should export the selected feature list "
"and pattern direction references."
),
})
if op.get("type") in ("fillet", "chamfer"):
selectors = op.get("selectors") or []
if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors):
issues.append({
"kind": "missing_original_feature_selection",
"feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")},
"message": (
"This feature only has final-geometry edge signatures. For exact replay "
"the plugin should export the original SolidWorks feature selections "
"including persistent references and selection marks."
),
})
return {
"safe_to_edit": not issues,
"issues": issues,
}
def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool:
stack = [selector]
while stack:
value = stack.pop()
if isinstance(value, dict):
if value.get("persistent_reference"):
return True
stack.extend(value.values())
elif isinstance(value, list):
stack.extend(value)
return False
def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]:
parameters: list[Dict[str, Any]] = []
sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])}
for op_index, op in enumerate(data.get("operations", [])):
op_type = op.get("type", "")
op_name = op.get("name", op.get("id", f"operation_{op_index}"))
op_path = f"/operations/{op_index}"
params = op.get("parameters", {})
if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params:
semantic = "body_length" if op_type == "extrude_add" else "cut_depth"
parameters.append(_editable_param(
id=f"{op.get('id', op_index)}.distance_mm",
label=f"{op_name} distance",
semantic=semantic,
unit="mm",
value=params.get("distance_mm"),
path=f"{op_path}/parameters/distance_mm",
feature=op,
))
if "reverse_distance_mm" in params:
parameters.append(_editable_param(
id=f"{op.get('id', op_index)}.reverse_distance_mm",
label=f"{op_name} reverse distance",
semantic="reverse_depth",
unit="mm",
value=params.get("reverse_distance_mm"),
path=f"{op_path}/parameters/reverse_distance_mm",
feature=op,
))
if op_type in ("fillet", "chamfer"):
key = "radius_mm" if op_type == "fillet" else "distance_mm"
if key in params:
parameters.append(_editable_param(
id=f"{op.get('id', op_index)}.{key}",
label=f"{op_name} {key}",
semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance",
unit="mm",
value=params.get(key),
path=f"{op_path}/parameters/{key}",
feature=op,
))
sketch_id = op.get("sketch")
sketch = sketches.get(sketch_id)
if sketch:
parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data))
return parameters
def _extract_sketch_parameters(
sketch: Dict[str, Any],
sketch_id: str,
op: Dict[str, Any],
op_index: int,
data: Dict[str, Any],
) -> list[Dict[str, Any]]:
parameters: list[Dict[str, Any]] = []
sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None)
if sketch_index is None:
return parameters
op_type = op.get("type", "")
entities = sketch.get("entities", [])
drawable = [entity for entity in entities if not entity.get("construction", False)]
for entity_index, entity in enumerate(entities):
entity_type = entity.get("type")
entity_path = f"/sketches/{sketch_index}/entities/{entity_index}"
if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"):
center = entity.get("center", [0, 0, 0])
radius = entity.get("radius_mm")
semantic = "hole" if op_type == "extrude_cut" else "circle_profile"
if radius is not None:
parameters.append(_editable_param(
id=f"{sketch_id}.entity{entity_index}.radius_mm",
label=f"{sketch.get('name', sketch_id)} circle radius",
semantic=f"{semantic}_radius",
unit="mm",
value=radius,
path=f"{entity_path}/radius_mm",
feature=op,
))
for axis, value in zip(("x", "y"), center[:2]):
parameters.append(_editable_param(
id=f"{sketch_id}.entity{entity_index}.center_{axis}",
label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}",
semantic=f"{semantic}_center_{axis}",
unit="mm",
value=value,
path=f"{entity_path}/center/{0 if axis == 'x' else 1}",
feature=op,
))
bounds = _sketch_bounds(drawable)
if bounds:
min_x, min_y, max_x, max_y = bounds
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
width = max_x - min_x
height = max_y - min_y
semantic_prefix = "slot" if op_type == "extrude_cut" else "profile"
for suffix, value, semantic in (
("center_x", center_x, f"{semantic_prefix}_center_x"),
("center_y", center_y, f"{semantic_prefix}_center_y"),
("width", width, f"{semantic_prefix}_width"),
("height", height, f"{semantic_prefix}_height"),
):
parameters.append(_editable_param(
id=f"{sketch_id}.{suffix}",
label=f"{sketch.get('name', sketch_id)} {suffix}",
semantic=semantic,
unit="mm",
value=value,
path=f"/sketches/{sketch_index}",
feature=op,
editable=False,
note="Derived from sketch entity bounds; edit underlying entities to change this safely.",
))
workplane = sketch.get("workplane", {})
origin = workplane.get("origin_mm")
if origin:
for axis, value in zip(("x", "y", "z"), origin[:3]):
parameters.append(_editable_param(
id=f"{sketch_id}.workplane_origin_{axis}",
label=f"{sketch.get('name', sketch_id)} workplane origin {axis}",
semantic=f"sketch_plane_origin_{axis}",
unit="mm",
value=value,
path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}",
feature=op,
))
return parameters
def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]:
points: list[tuple[float, float]] = []
for entity in entities:
for key in ("start", "end", "center"):
point = entity.get(key)
if point and len(point) >= 2:
points.append((float(point[0]), float(point[1])))
radius = entity.get("radius_mm")
center = entity.get("center")
if radius is not None and center and len(center) >= 2:
cx, cy = float(center[0]), float(center[1])
r = float(radius)
points.extend([(cx - r, cy - r), (cx + r, cy + r)])
if not points:
return None
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return min(xs), min(ys), max(xs), max(ys)
def _editable_param(
*,
id: str,
label: str,
semantic: str,
unit: str,
value: Any,
path: str,
feature: Dict[str, Any],
editable: bool = True,
note: Optional[str] = None,
) -> Dict[str, Any]:
result = {
"id": id,
"label": label,
"semantic": semantic,
"unit": unit,
"value": value,
"path": path,
"editable": editable,
"feature": {
"id": feature.get("id"),
"name": feature.get("name"),
"type": feature.get("type"),
"source_index": feature.get("source_feature", {}).get("index"),
},
}
if note:
result["note"] = note
return result
def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]:
"""Convert the current SW plugin feature dump into the backend IR."""
features = data.get("features", [])
sketches = []
operations = []
last_sketch_id = None
last_build_op = None
references = []
source_bbox = _source_bbox_from_plugin_json(data)
if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict):
operations.append(_convert_sw_assembly(data))
part_name = data.get("part_name", "part")
return {
"version": "ir-0.1",
"metadata": {
"source": {
"format": "sw-plugin-json",
"file_name": f"{part_name}.sldasm",
"sw_version": data.get("sw_version"),
}
},
"sketches": sketches,
"operations": operations,
"references": references,
"validation_hints": data.get("validation_hints", {}),
"geometry_inventory": data.get("geometry_inventory", {}),
"rebuild_contract": data.get("rebuild_contract", {}),
}
for index, feature in enumerate(features):
if feature.get("is_suppressed"):
continue
feature_type = feature.get("type", "")
type_name = feature.get("type_name", "")
feature_id = feature.get("id") or f"feat_{index:03d}"
feature_name = feature.get("name", feature_id)
if feature_type in ("refplane", "refaxis"):
references.append(_convert_sw_reference(feature, index))
elif feature_type == "sketch":
sketch_id = f"sketch_{len(sketches):03d}"
sketches.append(_convert_sw_sketch(feature, sketch_id, index))
last_sketch_id = sketch_id
elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict):
sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id
op = _convert_sw_extrude(feature, type_name, sketch_ref, index)
operations.append(op)
last_build_op = op
elif feature_type == "revolve":
sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id
op = _convert_sw_revolve(feature, type_name, sketch_ref, index)
operations.append(op)
last_build_op = op
elif feature_type == "hole":
op = _convert_sw_hole(feature, index)
operations.append(op)
last_build_op = op
elif feature_type == "pattern_linear":
data_block = feature.get("linear_pattern_data", {})
source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or [])
source_frame = _source_pattern_frame(source_op or last_build_op, sketches)
operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox))
elif feature_type == "pattern_mirror":
data_block = feature.get("mirror_data") or {}
src_features = data_block.get("source_features") or []
mirror_origin = data_block.get("mirror_plane_origin")
mirror_normal = data_block.get("mirror_plane_normal")
operations.append({
"id": feature_id,
"name": feature_name,
"type": "pattern_mirror",
"parameters": {"source_features": src_features},
"raw_parameters": {
"mirror_plane_origin": mirror_origin,
"mirror_plane_normal": mirror_normal,
},
"source_feature": _source_feature(feature, index),
})
elif feature_type == "fillet":
data_block = feature.get("fillet_data", {})
radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature)
operations.append({
"id": feature_id,
"name": feature_name,
"type": "fillet",
"parameters": {"radius_mm": radius_mm},
"selectors": _feature_selection_selectors(feature, data_block),
"selection_source": _feature_selection_source(feature, data_block),
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
})
elif feature_type == "chamfer":
data_block = feature.get("chamfer_data", {})
distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature)
operations.append({
"id": feature_id,
"name": feature_name,
"type": "chamfer",
"parameters": {"distance_mm": distance_mm},
"selectors": _feature_selection_selectors(feature, data_block),
"selection_source": _feature_selection_source(feature, data_block),
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
})
elif _is_imported_body_feature(feature):
op = _convert_sw_imported_body(feature, index)
operations.append(op)
last_build_op = op
elif feature_type == "moveface":
data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {}
op = {
"id": feature_id,
"name": feature_name,
"type": "move_face",
"parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block},
"source_feature": _source_feature(feature, index),
}
operations.append(op)
last_build_op = op
elif feature_type not in _SW_METADATA_FEATURE_TYPES:
operations.append({
"id": feature_id,
"name": feature_name,
"type": "unsupported",
"parameters": {"sw_type": type_name or feature_type},
"source_feature": _source_feature(feature, index),
})
part_name = data.get("part_name", "part")
return {
"version": "ir-0.1",
"metadata": {
"source": {
"format": "sw-plugin-json",
"file_name": f"{part_name}.sldprt",
"sw_version": data.get("sw_version"),
}
},
"sketches": sketches,
"operations": operations,
"references": references,
"validation_hints": data.get("validation_hints", {}),
"geometry_inventory": data.get("geometry_inventory", {}),
"rebuild_contract": data.get("rebuild_contract", {}),
}
def _is_imported_body_feature(feature: Dict[str, Any]) -> bool:
feature_type = str(feature.get("type") or "").lower()
type_name = str(feature.get("type_name") or "").lower()
return bool(feature.get("imported_body_data")) or feature_type in {
"mbimport",
"savedextbody",
"importedbody",
"imported",
"stock",
} or type_name in {"mbimport", "savedextbody", "importedbody"}
def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]:
data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {}
solid_bodies = data_block.get("solid_bodies") or []
solid_body_stats = data_block.get("solid_body_stats") or []
source_name = feature.get("name")
parameters = {
"sw_type": feature.get("type_name") or feature.get("type"),
"source_name": source_name,
"history_status": data_block.get("history_status"),
"body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats),
"solid_body_stats": solid_body_stats,
"solid_bodies": solid_bodies,
}
return {
"id": feature.get("id") or f"feat_{index:03d}",
"name": feature.get("name") or f"imported_body_{index:03d}",
"type": "imported_body",
"parameters": parameters,
"source_feature": _source_feature(feature, index),
"source_imported_body": data_block,
}
def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]:
assembly_data = data.get("assembly_data") or {}
components = []
for index, component in enumerate(assembly_data.get("components") or []):
if component.get("is_suppressed") or component.get("is_hidden"):
continue
path = component.get("path") or ""
component_name = component.get("name") or f"component_{index:03d}"
base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name
components.append({
"index": index,
"name": component_name,
"component_id": base_name,
"source_path": path,
"component_json": f"{base_name}.solidworks_rebuild_extract.json",
"transform": component.get("transform") or {},
})
return {
"id": "assembly_000",
"name": data.get("part_name") or "assembly",
"type": "assembly_compose",
"parameters": {
"components": components,
},
"source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"},
}
def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]:
"""Promote feature-owned SW sketches into the rebuild sketch table."""
source_sketches = []
for block_name in ("extrude_data", "revolve_data"):
block = feature.get(block_name)
if isinstance(block, dict):
source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict))
if not source_sketches:
return None
last_id = None
for sketch_data in source_sketches:
sketch_id = f"sketch_{len(sketches):03d}"
sketch_feature = dict(feature)
sketch_feature["sketch_data"] = sketch_data
if sketch_data.get("name"):
sketch_feature["name"] = sketch_data.get("name")
sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index))
last_id = sketch_id
return last_id
def get_part_name(data: Dict[str, Any]) -> str:
part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part")
part_name = str(part_name)
for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"):
if part_name.lower().endswith(suffix):
part_name = part_name[:-len(suffix)]
break
return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part"
def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str:
"""Generate build123d Python code from generic SW/build123d IR."""
rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {}
if rebuild_contract and rebuild_contract.get("ready") is False:
blockers = rebuild_contract.get("blockers") or []
raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}")
source_volume_mm3 = None
source_area_mm2 = None
mass_props = data.get("validation_hints", {}).get("mass_properties_raw")
if mass_props and len(mass_props) >= 5:
source_volume_mm3 = float(mass_props[3]) * 1_000_000_000
source_area_mm2 = float(mass_props[4]) * 1_000_000
lines = [
"from build123d import *",
"import math",
f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}",
f"SOURCE_AREA_MM2 = {source_area_mm2!r}",
"",
"def _dist(a, b):",
" return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))",
"",
"def _owned_face_match_score(shape, expected_faces):",
" if not expected_faces:",
" return 0.0",
" try:",
" available = list(shape.faces())",
" except Exception:",
" return 1e99",
" total = 0.0",
" for expected in expected_faces:",
" bbox_m = expected.get('box_m')",
" if not bbox_m or len(bbox_m) < 6 or not available:",
" total += 1e6",
" continue",
" target_box = [float(v) * 1000 for v in bbox_m[:6]]",
" surface = expected.get('surface') or {}",
" target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')",
" target_area = float(expected.get('area_m2') or 0) * 1_000_000",
" ranked = []",
" for index, face in enumerate(available):",
" try:",
" fb = face.bounding_box()",
" face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]",
" geom = face.geom_type() if callable(face.geom_type) else face.geom_type",
" geom_name = getattr(geom, 'name', str(geom)).lower()",
" type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0",
" bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))",
" area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0",
" ranked.append((type_penalty + bbox_penalty + area_penalty, index))",
" except Exception:",
" continue",
" if not ranked:",
" total += 1e6",
" continue",
" best, index = min(ranked, key=lambda item: item[0])",
" total += best",
" available.pop(index)",
" return total / max(len(expected_faces), 1)",
"",
"def _candidate_score(shape, expected_faces=None):",
" # Owned faces describe this exact SW history step. Final-part mass properties",
" # must not be used to choose an intermediate feature candidate.",
" if expected_faces:",
" return _owned_face_match_score(shape, expected_faces)",
" score = 0",
" if SOURCE_VOLUME_MM3 is not None:",
" try:",
" score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)",
" except Exception:",
" score += 1e99",
" if SOURCE_AREA_MM2 is not None:",
" try:",
" score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01",
" except Exception:",
" score += 1e99",
" score += _owned_face_match_score(shape, expected_faces)",
" return score",
"",
"def _edge_endpoints(edge):",
" vertices = [v.to_tuple() for v in edge.vertices()]",
" if len(vertices) != 2:",
" center = edge.center().to_tuple()",
" return center, center",
" return vertices[0], vertices[1]",
"",
"def _edge_match_score(edge, start, end):",
" a, b = _edge_endpoints(edge)",
" endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))",
" containment_score = edge.distance_to(start) + edge.distance_to(end)",
" return min(endpoint_score, containment_score)",
"",
"def select_edges_by_endpoints(part, selector_points, tolerance=0.5):",
" edges = list(part.edges())",
" selected = []",
" used = set()",
" for selector in selector_points:",
" start, end = selector",
" ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" score, index, edge = ranked[0]",
" if score > tolerance:",
" raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")",
" if index not in used:",
" selected.append(edge)",
" used.add(index)",
" return selected",
"",
"def _bbox_match_score(edge, bbox_mm):",
" if not bbox_mm or len(bbox_mm) < 6:",
" return float('inf')",
" try:",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" mins = tuple(float(bbox_mm[i]) for i in range(3))",
" maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))",
" diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.15)",
" def point_score(point):",
" total = 0.0",
" for axis in range(3):",
" if point[axis] < mins[axis] - pad:",
" total += mins[axis] - pad - point[axis]",
" elif point[axis] > maxs[axis] + pad:",
" total += point[axis] - maxs[axis] - pad",
" return total",
" return min(point_score(mid), (point_score(a) + point_score(b)) / 2)",
" except Exception:",
" return float('inf')",
"",
"def _circle_match_score(edge, circle_params):",
" if not circle_params or len(circle_params) < 7:",
" return float('inf')",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'CIRCLE' not in geom_name:",
" return float('inf')",
" target_center = tuple(float(v) * 1000 for v in circle_params[:3])",
" target_radius = float(circle_params[6]) * 1000",
" edge_center = edge.arc_center.to_tuple()",
" return _dist(edge_center, target_center) + abs(edge.radius - target_radius)",
" except Exception:",
" return float('inf')",
"",
"def _line_match_score(edge, line_params):",
" if not line_params or len(line_params) < 6:",
" return float('inf')",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" return float('inf')",
" target_point = tuple(float(v) * 1000 for v in line_params[:3])",
" target_dir = tuple(float(v) for v in line_params[3:6])",
" a, b = _edge_endpoints(edge)",
" edge_dir_raw = tuple(b[i] - a[i] for i in range(3))",
" length = math.sqrt(sum(v * v for v in edge_dir_raw))",
" if length <= 0:",
" return float('inf')",
" edge_dir = tuple(v / length for v in edge_dir_raw)",
" parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))",
" distance = edge.distance_to(target_point)",
" return distance + parallel * 10",
" except Exception:",
" return float('inf')",
"",
"def select_edges_by_selectors(part, selectors, tolerance=0.5):",
" if part is None:",
" return []",
" edges = list(part.edges())",
" selected = []",
" used = set()",
" for selector in selectors or []:",
" geometry = selector.get('geometry', {})",
" start_vertex = geometry.get('start_vertex')",
" end_vertex = geometry.get('end_vertex')",
" start = start_vertex.get('point_m') if start_vertex else None",
" end = end_vertex.get('point_m') if end_vertex else None",
" bbox_mm = geometry.get('bbox_mm')",
" if start and end:",
" start_mm = tuple(float(v) * 1000 for v in start)",
" end_mm = tuple(float(v) * 1000 for v in end)",
" line_params = geometry.get('curve', {}).get('line_params')",
" if line_params:",
" ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" line_params = geometry.get('curve', {}).get('line_params')",
" circle_params = geometry.get('curve', {}).get('circle_params')",
" if line_params:",
" ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" elif bbox_mm:",
" ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" score, index, edge = ranked[0]",
" selector_tolerance = float(selector.get('tolerance_mm') or tolerance)",
" if score > selector_tolerance:",
" # Skip edges that don't match well enough",
" continue",
" if index not in used:",
" selected.append(edge)",
" used.add(index)",
" return selected",
"",
"def _point_inside_bbox(point, bbox_mm, pad=0.25):",
" return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))",
"",
"def fillet_edges_from_owned_surface_bbox(part, selectors):",
" if part is None:",
" return []",
" boxes = []",
" seen_boxes = set()",
" for selector in selectors or []:",
" if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):",
" continue",
" bbox = (selector.get('geometry') or {}).get('bbox_mm')",
" if bbox and len(bbox) >= 6:",
" normalized = [float(v) for v in bbox[:6]]",
" key = tuple(round(v, 6) for v in normalized)",
" if key not in seen_boxes:",
" seen_boxes.add(key)",
" boxes.append(normalized)",
" if len(boxes) < 2:",
" return []",
" selected = []",
" used_keys = set()",
" for box in boxes:",
" diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.08)",
" sizes = [abs(box[i + 3] - box[i]) for i in range(3)]",
" thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]",
" circle_candidates = []",
" if thin_axes:",
" thin_axis = thin_axes[0]",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'CIRCLE' not in geom_name:",
" continue",
" eb = edge.bounding_box()",
" edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]",
" ok = True",
" score = 0.0",
" for axis in range(3):",
" if axis == thin_axis:",
" plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))",
" if plane_delta > pad:",
" ok = False",
" break",
" score += plane_delta",
" else:",
" if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:",
" ok = False",
" break",
" score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])",
" if not ok:",
" continue",
" key = tuple(round(v, 5) for v in edge_box)",
" circle_candidates.append((score, key, edge))",
" except Exception:",
" continue",
" if circle_candidates:",
" circle_candidates.sort(key=lambda item: item[0])",
" for _, key, edge in circle_candidates:",
" if key in used_keys:",
" continue",
" used_keys.add(key)",
" selected.append(edge)",
" break",
" continue",
" box_candidates = []",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" continue",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):",
" continue",
" key = tuple(round(v, 5) for point in (a, b) for v in point)",
" box_candidates.append((float(edge.length), key, edge))",
" except Exception:",
" continue",
" if not box_candidates:",
" continue",
" box_candidates.sort(key=lambda item: item[0], reverse=True)",
" for _, key, edge in box_candidates:",
" reverse_key = key[3:] + key[:3]",
" if key in used_keys or reverse_key in used_keys:",
" continue",
" used_keys.add(key)",
" selected.append(edge)",
" break",
" if selected:",
" return selected",
" union_bbox = [",
" min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)",
" for i in range(6)",
" ]",
" diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.05)",
" candidates = []",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" continue",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):",
" continue",
" candidates.append((float(edge.length), edge))",
" except Exception:",
" continue",
" if not candidates:",
" return []",
" candidates.sort(key=lambda item: item[0], reverse=True)",
" return [candidates[0][1]]",
"",
"def fillet_with_tolerance(edges, radius):",
" radii = [float(radius)]",
" shrink = max(0.001, abs(float(radius)) * 0.001)",
" if float(radius) > shrink:",
" radii.append(float(radius) - shrink)",
" radii.append(float(radius) * 0.99)",
" last_error = None",
" for candidate_radius in radii:",
" if candidate_radius <= 0:",
" continue",
" try:",
" return fillet(edges, radius=candidate_radius)",
" except Exception as exc:",
" last_error = exc",
" continue",
" if last_error:",
" raise last_error",
" return fillet(edges, radius=radius)",
"",
"def fillet_selected(part, radius, selectors, owned_faces=None):",
" if part is None:",
" return part",
" if not selectors:",
" # No edge selectors - skip fillet to avoid failing on all edges",
" return part",
" candidates = []",
" owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)",
" if owned_edges:",
" try:",
" candidates.append(fillet_with_tolerance(owned_edges, radius))",
" except Exception:",
" pass",
" try:",
" target_edges = select_edges_by_selectors(part, selectors)",
" if target_edges:",
" candidates.append(fillet_with_tolerance(target_edges, radius))",
" except Exception:",
" pass",
" result = part",
" applied_any = False",
" for selector in selectors:",
" edges = select_edges_by_selectors(result, [selector])",
" if not edges:",
" continue # Skip selectors that don't match any edge",
" try:",
" result = fillet_with_tolerance([edges[0]], radius)",
" applied_any = True",
" except Exception:",
" # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.",
" continue",
" if applied_any:",
" candidates.append(result)",
" variants = []",
" for selector in selectors:",
" edges = select_edges_by_selectors(part, [selector])",
" if not edges:",
" continue",
" try:",
" variants.append(fillet_with_tolerance([edges[0]], radius))",
" except Exception:",
" continue",
" if variants:",
" try:",
" union_result = part",
" for variant in variants:",
" union_result = union_result + variant",
" candidates.append(union_result)",
" except Exception:",
" pass",
" try:",
" intersection_result = part",
" for variant in variants:",
" intersection_result = intersection_result & variant",
" candidates.append(intersection_result)",
" except Exception:",
" pass",
" if candidates:",
" return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]",
" return part",
"",
"def chamfer_selected(part, distance, selectors, owned_faces=None):",
" if part is None:",
" return part",
" if not selectors:",
" # No edge selectors available - chamfer would fail on all edges",
" return part",
" candidates = []",
" owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)",
" if owned_edges:",
" try:",
" candidates.append(chamfer(owned_edges, length=distance))",
" except Exception:",
" pass",
" target_edges = select_edges_by_selectors(part, selectors)",
" if target_edges:",
" try:",
" candidates.append(chamfer(target_edges, length=distance))",
" except Exception:",
" pass",
" result = part",
" applied_any = False",
" for selector in selectors:",
" edges = select_edges_by_selectors(result, [selector])",
" if not edges:",
" continue",
" try:",
" result = chamfer([edges[0]], length=distance)",
" applied_any = True",
" except Exception:",
" continue",
" if applied_any:",
" candidates.append(result)",
" if candidates:",
" return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]",
" return part",
"",
"def is_internal_cone_face(face, part):",
" try:",
" bbox_m = face.get('box_m')",
" surface = face.get('surface') or {}",
" if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):",
" return False",
" params = surface.get('cone_params')",
" if not params or len(params) < 6:",
" return False",
" direction = tuple(float(v) for v in params[3:6])",
" axis = max(range(3), key=lambda i: abs(direction[i]))",
" radial_axes = tuple(i for i in range(3) if i != axis)",
" part_bbox = part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" tol = 0.25",
" touches_outer = any(",
" abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol",
" for i in radial_axes",
" )",
" return not touches_outer",
" except Exception:",
" return False",
"",
"def is_external_cone_face(face, part):",
" try:",
" bbox_m = face.get('box_m')",
" surface = face.get('surface') or {}",
" if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):",
" return False",
" params = surface.get('cone_params')",
" if not params or len(params) < 6:",
" return False",
" direction = tuple(float(v) for v in params[3:6])",
" axis = max(range(3), key=lambda i: abs(direction[i]))",
" radial_axes = tuple(i for i in range(3) if i != axis)",
" part_bbox = part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" tol = 0.25",
" return any(",
" abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol",
" for i in radial_axes",
" )",
" except Exception:",
" return False",
"",
"def make_owned_external_cone_chamfer_cutter(face):",
" surface = face.get('surface') or {}",
" params = surface.get('cone_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" base_radius = abs(float(params[6]) * 1000)",
" half_angle = abs(float(params[7]))",
" if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" delta = (x - origin[0], y - origin[1], z - origin[2])",
" axial = sum(delta[i] * direction[i] for i in range(3))",
" projections.append(axial)",
" start = min(projections)",
" end = max(projections)",
" height = max(0.001, end - start)",
" r1 = max(0.0, base_radius - math.tan(half_angle) * start)",
" r2 = max(0.0, base_radius - math.tan(half_angle) * end)",
" outer_radius = max(r1, r2) + 0.001",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" if r1 <= 1e-9:",
" r1 = 1e-6",
" if r2 <= 1e-9:",
" r2 = 1e-6",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)",
" return cutter_part.part",
"",
"def chamfer_owned_external_cones(part, faces):",
" if part is None:",
" return part, False",
" result = part",
" applied = False",
" for face in faces or []:",
" if not is_external_cone_face(face, result):",
" continue",
" cutter = make_owned_external_cone_chamfer_cutter(face)",
" new_result = safe_subtract(result, cutter)",
" if new_result is not result:",
" result = new_result",
" applied = True",
" return result, applied",
"",
"def chamfer_owned_internal_cones(part, faces):",
" if part is None:",
" return part, False",
" result = part",
" applied = False",
" for face in faces or []:",
" if not is_internal_cone_face(face, result):",
" continue",
" cutter = make_owned_cone_cutter(face)",
" new_result = safe_subtract(result, cutter)",
" if new_result is not result:",
" result = new_result",
" applied = True",
" return result, applied",
"",
"def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):",
" cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]",
" if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):",
" result, applied = chamfer_owned_internal_cones(part, cone_faces)",
" if applied:",
" return result",
" if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):",
" result, applied = chamfer_owned_external_cones(part, cone_faces)",
" if applied:",
" return result",
" return chamfer_selected(part, distance, selectors, owned_faces)",
"",
"def safe_subtract(part, cutter):",
" if part is None or cutter is None:",
" return part",
" try:",
" vol_before = float(part.volume)",
" except Exception:",
" vol_before = -1",
" try:",
" cut = part - cutter",
" if cut is None:",
" print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')",
" return part",
" # Accept the cut even when solids() reports 0 can happen",
" # for valid boolean results with non-standard structures.",
" try:",
" nb_solids = len(list(cut.solids()))",
" if nb_solids == 0:",
" print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')",
" except Exception:",
" pass",
" return cut",
" except Exception as e:",
" print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')",
" return part",
"",
"def _project_bbox_along_direction(bbox, origin, direction):",
" mins = tuple(float(bbox[i]) for i in range(3))",
" maxs = tuple(float(bbox[i + 3]) for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))",
" return min(projections), max(projections)",
"",
"def make_owned_cylinder_cutter(face, target_part=None):",
" surface = face.get('surface') or {}",
" params = surface.get('cylinder_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" radius = abs(float(params[6]) * 1000)",
" if norm <= 1e-9 or radius <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)",
" if target_part is not None:",
" try:",
" part_bbox = target_part.bounding_box()",
" part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())",
" part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)",
" through_tolerance = max(1.0, radius * 0.12)",
" if abs(start - part_start) <= through_tolerance:",
" start = part_start",
" if abs(end - part_end) <= through_tolerance:",
" end = part_end",
" except Exception:",
" pass",
" height = max(0.001, end - start)",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" return cutter_part.part",
"",
"def make_owned_cone_cutter(face):",
" surface = face.get('surface') or {}",
" params = surface.get('cone_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" base_radius = abs(float(params[6]) * 1000)",
" half_angle = abs(float(params[7]))",
" if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))",
" start = min(projections)",
" end = max(projections)",
" # Keep a tiny overlap for the boolean while preserving blind-hole depth.",
" height = max(0.001, end - start) + 0.001",
" # SolidWorks ConeParams stores the radius at the cone origin; along the axis",
" # direction the radius tapers rather than expands for hole drill tips.",
" r1 = max(0.0, base_radius - math.tan(half_angle) * start)",
" r2 = max(0.0, base_radius - math.tan(half_angle) * end)",
" if max(r1, r2) <= 1e-9:",
" return None",
" if r1 <= 1e-9:",
" r1 = 1e-6",
" if r2 <= 1e-9:",
" r2 = 1e-6",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" return cutter_part.part",
"",
"def make_owned_face_cutter(face, target_part=None):",
" surface = face.get('surface') or {}",
" if surface.get('is_cylinder'):",
" return make_owned_cylinder_cutter(face, target_part)",
" if surface.get('is_cone'):",
" return make_owned_cone_cutter(face)",
" return None",
"",
"def cut_owned_cylindrical_faces(part, faces):",
" result = part",
" for face in faces or []:",
" cutter = make_owned_face_cutter(face, result)",
" result = safe_subtract(result, cutter)",
" return result",
"",
"def make_owned_flip_side_ring_cutter(face, target_part):",
" surface = face.get('surface') or {}",
" params = surface.get('cylinder_params')",
" bbox_m = face.get('box_m')",
" if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" inner_radius = abs(float(params[6]) * 1000)",
" if norm <= 1e-9 or inner_radius <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)",
" height = max(0.001, end - start)",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" try:",
" part_bbox = target_part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" radial = []",
" for x in (part_min[0], part_max[0]):",
" for y in (part_min[1], part_max[1]):",
" for z in (part_min[2], part_max[2]):",
" delta = (x - origin[0], y - origin[1], z - origin[2])",
" axial = sum(delta[i] * direction[i] for i in range(3))",
" perp = tuple(delta[i] - axial * direction[i] for i in range(3))",
" radial.append(math.sqrt(sum(v * v for v in perp)))",
" outer_radius = max(radial) + max(1.0, inner_radius * 0.05)",
" except Exception:",
" outer_radius = inner_radius + 100.0",
" if outer_radius <= inner_radius + 1e-6:",
" return None",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)",
" return cutter_part.part",
"",
"def cut_owned_flip_side_cylindrical_faces(part, faces):",
" result = part",
" for face in faces or []:",
" cutter = make_owned_flip_side_ring_cutter(face, result)",
" result = safe_subtract(result, cutter)",
" return result",
"",
"def cut_owned_bbox(part, bbox_mm):",
" if part is None or not bbox_mm or len(bbox_mm) < 6:",
" return part",
" mins = tuple(float(bbox_mm[i]) for i in range(3))",
" maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))",
" size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))",
" center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))",
" cutter = Pos(center) * Box(size[0], size[1], size[2])",
" return safe_subtract(part, cutter)",
"",
"def shape_face_count(shape):",
" if shape is None:",
" return 0",
" try:",
" return len(list(shape.faces()))",
" except Exception:",
" return 0",
"",
"def safe_union(part, solid, preserve_visible=False):",
" if part is None:",
" return solid",
" if solid is None:",
" return part",
" try:",
" fused = part + solid",
" # OCCT fuse succeeded; always return the fused result.",
" # is_valid() can return False for edge cases where the geometry",
" # is actually correct (e.g. touching-at-faces). Accept it.",
" return fused",
" except Exception as e:",
" print(f' UNION: fuse threw {type(e).__name__}: {e}')",
" pass",
" try:",
" compound = Compound.make_composite([part, solid])",
" fused = compound.fuse()",
" try:",
" if len(list(fused.solids())) > 0:",
" print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')",
" return fused",
" except Exception:",
" pass",
" except Exception as e:",
" print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')",
" pass",
" shapes = []",
" try:",
" shapes.extend(list(part.solids()))",
" except Exception:",
" shapes.append(part)",
" try:",
" shapes.extend(list(solid.solids()))",
" except Exception:",
" shapes.append(solid)",
" return Compound.make_composite(shapes)",
"",
"def sw_inverted_profile_cut(part, profile_solid, normal):",
" if part is None or profile_solid is None:",
" return part",
" try:",
" part_bbox = part.bounding_box()",
" profile_bbox = profile_solid.bounding_box()",
" n = tuple(float(v) for v in normal)",
" axis = max(range(3), key=lambda i: abs(n[i]))",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" prof_min = profile_bbox.min.to_tuple()",
" prof_max = profile_bbox.max.to_tuple()",
" margin = 5.0",
" mins = [part_min[i] - margin for i in range(3)]",
" maxs = [part_max[i] + margin for i in range(3)]",
" mins[axis] = prof_min[axis] - margin * 0.05",
" maxs[axis] = prof_max[axis] + margin * 0.05",
" center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))",
" size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))",
" envelope = Pos(center) * Box(size[0], size[1], size[2])",
" outside_profile = safe_subtract(envelope, profile_solid)",
" return safe_subtract(part, outside_profile)",
" except Exception:",
" return part",
"",
"def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):",
" part = sw_inverted_profile_cut(part, profile_solid, normal)",
" if part is None or profile_solid is None:",
" return part",
" try:",
" outer_radius = abs(float(outer_radius_mm))",
" inner_radius = abs(float(inner_radius_mm))",
" except Exception:",
" return part",
" if outer_radius <= inner_radius + 1e-6:",
" return part",
" try:",
" profile_bbox = profile_solid.bounding_box()",
" prof_min = profile_bbox.min.to_tuple()",
" prof_max = profile_bbox.max.to_tuple()",
" center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))",
" n = tuple(float(v) for v in normal)",
" axis = max(range(3), key=lambda i: abs(n[i]))",
" span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])",
" margin_xy = max(2.0, span_xy * 0.05)",
" margin_z = 0.1",
" size = tuple(",
" max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))",
" for i in range(3)",
" )",
" plane = Plane(",
" origin=center,",
" x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),",
" z_dir=n,",
" )",
" cut_extent = prof_max[axis] - prof_min[axis]",
" cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)",
" with BuildSketch(plane) as ring_sketch:",
" Circle(outer_radius)",
" Circle(inner_radius, mode=Mode.SUBTRACT)",
" ring = extrude(ring_sketch.sketch, amount=cut_amount)",
" return safe_union(part, ring)",
" except Exception:",
" return part",
"",
"def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):",
" if part is None:",
" return part",
" if not positions or diameter <= 0 or depth <= 0:",
" return part",
" plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]",
" frame = host_face.get('frame') or {}",
" normal = tuple(float(v) for v in plane[:3])",
" plane_point = tuple(float(v) * 1000 for v in plane[3:6])",
" origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))",
" x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))",
" y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))",
" has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0",
" bbox = part.bounding_box()",
" part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))",
" toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))",
" dot = sum(toward_center[i] * normal[i] for i in range(3))",
" inward = normal if dot >= 0 else tuple(-v for v in normal)",
" axis = max(range(3), key=lambda i: abs(inward[i]))",
" rotation = (0, 0, 0)",
" if axis == 0:",
" rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)",
" elif axis == 1:",
" rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)",
" elif inward[2] < 0:",
" rotation = (180, 0, 0)",
" tip_depth = 0",
" if include_drill_tip and drill_angle > 0:",
" tip_depth = (diameter / 2) / math.tan(drill_angle / 2)",
" countersink_depth = 0",
" if countersink_diameter > diameter and countersink_angle > 0:",
" countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)",
" result = part",
" for pos in positions:",
" x, y = float(pos[0]), float(pos[1])",
" if has_frame:",
" start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))",
" elif axis == 0:",
" start = (plane_point[0], x, y)",
" elif axis == 1:",
" start = (x, plane_point[1], -y)",
" else:",
" start = (x, y, plane_point[2])",
" cut_depth = depth",
" if depth >= 199:",
" part_min = bbox.min.to_tuple()",
" part_max = bbox.max.to_tuple()",
" corners = []",
" for ci in range(2):",
" for cj in range(2):",
" for ck in range(2):",
" corners.append((",
" part_min[0] if ci else part_max[0],",
" part_min[1] if cj else part_max[1],",
" part_min[2] if ck else part_max[2],",
" ))",
" cut_depth = max(",
" sum((corner[i] - start[i]) * inward[i] for i in range(3))",
" for corner in corners",
" ) + 2.0",
" cutters = []",
" cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0",
" cs_depth = countersink_depth if countersink_depth > 0 else 0",
" hole_start = cs_depth",
" hole_depth = max(0.001, cut_depth - hole_start - cb_depth)",
" if hole_depth > 0:",
" hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))",
" cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))",
" if cb_depth > 0:",
" cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))",
" cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))",
" if cs_depth > 0:",
" cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))",
" cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)",
" cutters.append(cs)",
" if tip_depth > 0:",
" base = tuple(start[i] + inward[i] * cut_depth for i in range(3))",
" tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))",
" tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)",
" cutters.append(tip)",
" if len(cutters) == 1:",
" cutter = cutters[0]",
" else:",
" cutter = Compound.make_composite(cutters)",
" result = safe_subtract(result, cutter)",
" return result",
"",
]
part_name_clean = get_part_name(data)
lines.append(f"def build_{part_name_clean}():")
lines.append(' """Auto-generated build123d code from SolidWorks IR."""')
lines.append("")
sketches = {s["id"]: s for s in data.get("sketches", [])}
operations = data.get("operations", [])
references = {r["id"]: r for r in data.get("references", [])}
generated_sketches = set()
lines.append(" result = None")
lines.append("")
for op in sort_operations_for_history(operations):
op_type = op.get("type", "")
op_name = op.get("name", "")
if op_type in ["unsupported", "unknown"]:
lines.append(f" # Skipping unsupported metadata feature: {op_name}")
lines.append("")
continue
if op_type == "imported_body":
lines.extend(_generate_imported_body_pending(op))
elif op_type == "assembly_compose":
lines.extend(_generate_assembly_compose(op))
elif op_type == "move_face":
lines.extend(_generate_move_face(op))
elif op_type == "fillet":
lines.extend(_generate_fillet(op))
elif op_type == "chamfer":
lines.extend(_generate_chamfer(op))
elif op_type == "hole":
lines.extend(_generate_hole(op))
elif op_type in ("extrude_cut", "extrude_add"):
build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or ""))
sketch_id = op.get("sketch")
if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]):
lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}")
continue
if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches:
lines.extend(_generate_sketch(sketches[sketch_id], references, build_op))
generated_sketches.add(sketch_id)
lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches))
elif op_type in ("revolve_cut", "revolve_add"):
sketch_id = op.get("sketch")
if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]):
lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}")
continue
if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches:
lines.extend(_generate_sketch(sketches[sketch_id], references, op))
generated_sketches.add(sketch_id)
lines.extend(_generate_revolve(op, sketches.get(sketch_id, {})))
elif op_type in ("linear_pattern", "pattern_linear"):
lines.extend(_generate_linear_pattern(op, operations, sketches, references))
elif op_type == "pattern_mirror":
lines.extend(_generate_mirror_pattern(op, operations, sketches, references))
else:
lines.append(f" # TODO: {op_type} - {op_name}")
lines.append("")
lines.append(" if result is None:")
lines.append(' raise Exception("No solid was created")')
lines.append("")
lines.append(" # Clean up small inaccuracies from Boolean operations")
lines.append(" try:")
lines.append(" result = result.clean()")
lines.append(" except Exception:")
lines.append(" pass")
lines.append(f' export_step(result, "{part_name_clean}.step")')
lines.append(" return result")
lines.append("")
lines.append("# Run the function")
lines.append('if __name__ == "__main__":')
lines.append(f" build_{part_name_clean}()")
return "\n".join(lines)
def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]:
return [
f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}",
" raise NotImplementedError(",
" 'Pure-JSON imported-body reconstruction is not implemented yet; '",
" 'the plugin captured solid_bodies topology and the part is marked not ready.'",
" )",
]
def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]:
params = op.get("parameters") or {}
components = params.get("components") or []
component_ids = [component.get("component_id") for component in components]
message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}"
return [
f" # Pure-JSON assembly composition: {op.get('name', '')}",
" raise NotImplementedError(",
f" {message!r}",
" )",
]
def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]:
if not isinstance(array_data, list) or len(array_data) < 13:
raise ValueError(f"Assembly component {component_name} has no complete 16-value transform")
values = [float(value or 0) for value in array_data]
scale = values[12]
if abs(scale) <= 1e-12:
raise ValueError(f"Assembly component {component_name} has an invalid zero scale")
# SOLIDWORKS stores row-vector axes and translation in elements 9..11.
# build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose.
return [
[values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0],
[values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0],
[values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0],
[0.0, 0.0, 0.0, 1.0],
]
def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
"""Return operations in SW rebuild order."""
if _looks_like_reverse_history(operations):
return list(reversed(operations))
if all(op.get("source_feature", {}).get("index") is not None for op in operations):
return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0))
return sorted(operations, key=_operation_priority)
def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool:
build_ops = [
op
for op in operations
if op.get("type") not in ("unsupported", "unknown")
]
if len(build_ops) < 2:
return False
additive = {"extrude_add", "revolve_add", "sweep", "loft"}
downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"}
return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive
def _operation_priority(op: Dict[str, Any]) -> int:
op_type = op.get("type", "")
if op_type == "extrude_add":
return 0
if op_type in ("extrude_cut", "revolve_cut"):
return 1
if op_type == "revolve_add":
return 2
if op_type in ("fillet", "chamfer"):
return 3
if op_type in ("sweep", "loft"):
return 4
return 99
def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool:
for entity in sketch.get("entities", []) or []:
if entity.get("construction"):
continue
if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0:
return True
if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0:
return True
valid_lines = 0
for entity in sketch.get("entities", []) or []:
if entity.get("construction") or entity.get("type") != "line":
continue
start = entity.get("start") or [0, 0]
end = entity.get("end") or [0, 0]
if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6:
valid_lines += 1
return valid_lines >= 2
def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None:
if not isinstance(point, list) or len(point) < 2:
return None
return (round(float(point[0]), places), round(float(point[1]), places))
def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]:
"""Reverse a sketch segment while preserving its geometric traversal."""
reversed_ent = dict(ent)
reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start")
reversed_ent["reversed"] = not bool(ent.get("reversed", False))
if ent.get("type") == "arc":
raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {}
axis = ent.get("curve_axis") or raw.get("curve_axis")
if isinstance(axis, list) and len(axis) >= 3:
# The arc's endpoints and orientation are a pair. Keep the
# source `raw` untouched, but provide a flipped top-level axis for
# code generation so a reversed minor arc remains a minor arc.
reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]]
# 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度,
# 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。
reversed_ent.pop("start_angle_deg", None)
reversed_ent.pop("end_angle_deg", None)
reversed_ent.pop("arc_sweep_deg", None)
return reversed_ent
def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
"""Order sketch line/arc entities into connected loops when SW did not export contours."""
drawable = [
ent for ent in entities
if ent.get("type") in ("line", "arc")
and _point_key(ent.get("start")) is not None
and _point_key(ent.get("end")) is not None
]
if len(drawable) < 3:
return entities
by_node: dict[tuple[float, float], list[tuple[int, str]]] = {}
for idx, ent in enumerate(drawable):
by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start"))
by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end"))
if not by_node or any(len(touches) != 2 for touches in by_node.values()):
return entities
remaining = set(range(len(drawable)))
ordered: list[Dict[str, Any]] = []
while remaining:
first_idx = min(remaining)
remaining.remove(first_idx)
first = drawable[first_idx]
loop = [first]
loop_start = _point_key(first.get("start"))
cursor = _point_key(first.get("end"))
while cursor != loop_start:
next_idx = None
next_side = None
for candidate_idx, side in by_node.get(cursor, []):
if candidate_idx in remaining:
next_idx = candidate_idx
next_side = side
break
if next_idx is None:
return entities
remaining.remove(next_idx)
next_ent = drawable[next_idx]
if next_side == "end":
next_ent = _reverse_curve_entity(next_ent)
loop.append(next_ent)
cursor = _point_key(next_ent.get("end"))
ordered.extend(loop)
return ordered
def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
drawable = [
(idx, ent) for idx, ent in enumerate(entities)
if not ent.get("construction", False)
and ent.get("type") in ("line", "arc")
and _point_key(ent.get("start")) is not None
and _point_key(ent.get("end")) is not None
]
if len(drawable) < 3:
return []
by_node: dict[tuple[float, float], list[tuple[int, str]]] = {}
for local_idx, (_, ent) in enumerate(drawable):
by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start"))
by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end"))
remaining = set(range(len(drawable)))
loops: list[Dict[str, Any]] = []
while remaining:
first_idx = min(remaining)
remaining.remove(first_idx)
_, first = drawable[first_idx]
loop_indices = [first_idx]
loop_start = _point_key(first.get("start"))
cursor = _point_key(first.get("end"))
while cursor != loop_start:
matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining]
if not matches:
loop_indices = []
break
next_idx, next_side = matches[0]
remaining.remove(next_idx)
_, next_ent = drawable[next_idx]
loop_indices.append(next_idx)
cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end"))
if not loop_indices:
continue
entity_indices = [drawable[idx][0] for idx in loop_indices]
bbox = _loop_bbox([entities[idx] for idx in entity_indices])
loops.append({
"entity_indices": entity_indices,
"is_closed": True,
"bbox_mm": bbox,
"bbox_area_mm2": _bbox_area_2d(bbox),
"source": "inferred_connected_loop",
})
return loops
def _loop_bbox(entities: list[Dict[str, Any]]) -> Optional[list[float]]:
points = []
for ent in entities:
if not isinstance(ent, dict):
continue
if ent.get("type") == "circle":
center = ent.get("center")
radius = ent.get("radius_mm")
if isinstance(center, list) and len(center) >= 2 and radius is not None:
radius_value = abs(float(radius))
points.append([float(center[0]) - radius_value, float(center[1]) - radius_value])
points.append([float(center[0]) + radius_value, float(center[1]) + radius_value])
continue
for key in ("start", "end", "center"):
point = ent.get(key)
if isinstance(point, list) and len(point) >= 2:
points.append(point)
if not points:
return None
return [
min(float(point[0]) for point in points),
min(float(point[1]) for point in points),
max(float(point[0]) for point in points),
max(float(point[1]) for point in points),
]
def _bbox_area_2d(bbox: Optional[list[float]]) -> float:
if not isinstance(bbox, list) or len(bbox) < 4:
return 0.0
return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1]))
def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool:
if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4:
return False
return (
float(outer[0]) <= float(inner[0]) + tolerance
and float(outer[1]) <= float(inner[1]) + tolerance
and float(outer[2]) >= float(inner[2]) - tolerance
and float(outer[3]) >= float(inner[3]) - tolerance
)
def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float:
if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4:
return 0.0
ix0 = max(float(a[0]), float(b[0]))
iy0 = max(float(a[1]), float(b[1]))
ix1 = min(float(a[2]), float(b[2]))
iy1 = min(float(a[3]), float(b[3]))
intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0)
smaller = min(_bbox_area_2d(a), _bbox_area_2d(b))
if smaller <= 1e-9:
return 0.0
return intersection / smaller
def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]:
radii: list[float] = []
for idx in loop.get("entity_indices", []) or []:
if not isinstance(idx, int) or idx < 0 or idx >= len(entities):
continue
ent = entities[idx]
radius = ent.get("radius_mm")
if radius is not None:
radii.append(abs(float(radius)))
bbox = loop.get("bbox_mm")
if isinstance(bbox, list) and len(bbox) >= 4:
radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2)
radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2)
return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)]
def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]:
if not isinstance(operation, dict):
return []
radii: list[float] = []
loop_radii: list[float] = []
entities = sketch.get("entities") if isinstance(sketch, dict) else []
sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else []
for loop in sketch_loops:
loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else []))
def _matches_sketch_radius(value: float) -> bool:
return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii)
for face in operation.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
params = surface.get("cylinder_params")
if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7:
radii.append(abs(float(params[6]) * 1000))
continue
box = face.get("box_m")
area = face.get("area_m2")
if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None:
sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)]
non_zero_sizes = [size for size in sizes if size > 1e-4]
if len(non_zero_sizes) >= 2:
outer_radius = max(non_zero_sizes) / 2
area_mm2 = abs(float(area)) * 1_000_000
inner_sq = outer_radius * outer_radius - area_mm2 / math.pi
inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0
if _matches_sketch_radius(outer_radius):
radii.append(outer_radius)
if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius):
radii.append(inner_radius)
unique: list[float] = []
for radius in sorted(radii):
if radius <= 1e-6 or not math.isfinite(radius):
continue
if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique):
unique.append(radius)
return unique
def _loops_matching_owned_radii(
loops: list[Dict[str, Any]],
entities: list[Dict[str, Any]],
owned_radii: list[float],
) -> list[Dict[str, Any]]:
if not loops or not owned_radii:
return []
matched: list[tuple[float, Dict[str, Any]]] = []
for loop in loops:
candidates = _loop_radius_candidates(loop, entities)
if not candidates:
continue
best_radius = None
best_delta = float("inf")
for candidate in candidates:
for owned_radius in owned_radii:
delta = abs(candidate - owned_radius)
if delta < best_delta:
best_delta = delta
best_radius = candidate
if best_radius is None:
continue
if best_delta <= max(0.1, best_radius * 0.01):
matched.append((best_radius, loop))
if not matched:
return []
matched.sort(key=lambda item: item[0], reverse=True)
deduped: list[tuple[float, Dict[str, Any]]] = []
seen_loop_keys: set[str] = set()
for radius, loop in matched:
bbox = loop.get("bbox_mm")
key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices"))
key = f"{radius:.4f}:{key}"
if key in seen_loop_keys:
continue
seen_loop_keys.add(key)
deduped.append((radius, loop))
matched = deduped
annotated = []
for index, (_, loop) in enumerate(matched):
loop_copy = dict(loop)
loop_copy["profile_mode"] = "add" if index == 0 else "subtract"
annotated.append(loop_copy)
return annotated
def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]:
if not loops:
return None
area = 0.0
for index, loop in enumerate(loops):
radii = _loop_radius_candidates(loop, entities)
if not radii:
return None
radius = max(radii)
mode = loop.get("profile_mode")
sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1
area += sign * math.pi * radius * radius
return abs(area) if area > 1e-6 else None
def _aligned_workplane_for_owned_midplane(
sketch: Dict[str, Any],
operation: Optional[Dict[str, Any]],
loops: list[Dict[str, Any]],
) -> Dict[str, Any]:
workplane = dict(sketch.get("workplane") or {})
if not isinstance(operation, dict) or operation.get("type") != "extrude_add":
return workplane
params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {}
if not params.get("both_directions"):
return workplane
entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else []
profile_area = _loop_area_from_radii(loops, entities)
if profile_area is None:
return workplane
normal = workplane.get("normal") or [0, 0, 1]
origin = workplane.get("origin_mm") or [0, 0, 0]
if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3:
return workplane
normal_vec = [float(v) for v in normal[:3]]
norm = math.sqrt(sum(v * v for v in normal_vec))
if norm <= 1e-9:
return workplane
normal_vec = [v / norm for v in normal_vec]
candidates: list[tuple[float, list[float]]] = []
for face in operation.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
if not surface.get("is_plane"):
continue
area_m2 = face.get("area_m2")
plane_params = surface.get("plane_params")
if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6:
continue
face_area = abs(float(area_m2)) * 1_000_000
if abs(face_area - profile_area) > max(0.5, profile_area * 0.02):
continue
plane_normal = [float(v) for v in plane_params[:3]]
plane_norm = math.sqrt(sum(v * v for v in plane_normal))
if plane_norm <= 1e-9:
continue
plane_normal = [v / plane_norm for v in plane_normal]
alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3)))
if alignment < 0.98:
continue
plane_point = [float(v) * 1000 for v in plane_params[3:6]]
old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3))
new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3))
delta = new_offset - old_offset
if abs(delta) <= 1e-6:
continue
moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)]
candidates.append((abs(delta), moved_origin))
if len(candidates) != 1:
return workplane
candidates.sort(key=lambda item: item[0])
workplane["origin_mm"] = candidates[0][1]
return workplane
def _project_owned_faces_to_sketch_bbox(
owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any]
) -> Optional[list[float]]:
origin = workplane.get("origin_mm") or [0, 0, 0]
x_dir = workplane.get("x_dir") or [1, 0, 0]
y_dir = workplane.get("y_dir") or [0, 1, 0]
if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3:
return None
projected: list[tuple[float, float]] = []
for face in owned_faces:
box = face.get("box_m") if isinstance(face, dict) else None
if not isinstance(box, list) or len(box) < 6:
continue
mins = [float(box[i]) * 1000 for i in range(3)]
maxs = [float(box[i + 3]) * 1000 for i in range(3)]
for x in (mins[0], maxs[0]):
for y in (mins[1], maxs[1]):
for z in (mins[2], maxs[2]):
point = [x, y, z]
rel = [point[i] - float(origin[i]) for i in range(3)]
projected.append((
sum(rel[i] * float(x_dir[i]) for i in range(3)),
sum(rel[i] * float(y_dir[i]) for i in range(3)),
))
if not projected:
return None
return [
min(point[0] for point in projected),
min(point[1] for point in projected),
max(point[0] for point in projected),
max(point[1] for point in projected),
]
def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]:
entities = sketch.get("entities", []) or []
loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities)
if not loops:
return []
op_type = operation.get("type") if isinstance(operation, dict) else None
if op_type == "extrude_cut" and len(loops) > 1:
owned_bbox = _project_owned_faces_to_sketch_bbox(
operation.get("source_owned_faces") or [],
sketch.get("workplane") or {},
)
if owned_bbox:
for inner in loops:
inner_bbox = inner.get("bbox_mm")
if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85:
continue
containers = [
outer for outer in loops
if outer is not inner
and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4)
and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05
]
if containers:
outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm")))
outer_loop = dict(outer)
inner_loop = dict(inner)
outer_loop["profile_mode"] = "add"
inner_loop["profile_mode"] = "subtract"
return [outer_loop, inner_loop]
active = []
for loop in loops:
bbox = loop.get("bbox_mm")
area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox))
contains_other = any(
other is not loop
and _bbox_contains_2d(bbox, other.get("bbox_mm"))
and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05
for other in loops
)
if not contains_other:
active.append(loop)
if active:
return active
if op_type == "extrude_add" and len(loops) > 1:
owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch))
# Owned-face radii are useful for selecting circular profiles, but a
# rounded outer contour also contributes arc radii. Those radii can
# coincide with an inner circle and make the radius ranking label the
# inner loop as ADD and its containing outer loop as SUBTRACT. Such a
# profile is topologically impossible as a first additive sketch, so
# fall back to the complete contour nesting below.
owned_modes_conflict_with_nesting = any(
candidate.get("profile_mode") == "add"
and any(
container is not candidate
and container.get("profile_mode") == "subtract"
and _bbox_contains_2d(
container.get("bbox_mm"),
candidate.get("bbox_mm"),
tolerance=1e-4,
)
and _bbox_area_2d(container.get("bbox_mm"))
> _bbox_area_2d(candidate.get("bbox_mm")) * 1.05
for container in owned_matched
)
for candidate in owned_matched
)
if owned_modes_conflict_with_nesting:
owned_matched = []
if owned_matched:
# Radius evidence cannot identify closed slot/polygon contours.
# Keep non-circular closed loops that lie inside an owned additive
# outer loop; they are material-removal islands in the same
# additive sketch. Circular unmatched loops remain excluded
# because they commonly belong to other features sharing a sketch.
matched_entity_keys = {
tuple(loop.get("entity_indices") or []) for loop in owned_matched
}
additive_outers = [
loop for loop in owned_matched if loop.get("profile_mode") == "add"
]
for loop in loops:
entity_indices = tuple(loop.get("entity_indices") or [])
if entity_indices in matched_entity_keys:
continue
profile_entities = [
entities[index]
for index in entity_indices
if isinstance(index, int) and 0 <= index < len(entities)
]
is_non_circular_profile = bool(profile_entities) and any(
entity.get("type") != "circle"
and not (entity.get("type") == "arc" and entity.get("is_circle"))
for entity in profile_entities
)
if not is_non_circular_profile:
continue
if not any(
_bbox_contains_2d(
outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4
)
for outer in additive_outers
):
continue
loop_copy = dict(loop)
loop_copy["profile_mode"] = "subtract"
owned_matched.append(loop_copy)
if len(owned_matched) == 1 and isinstance(operation, dict):
outer_loop = owned_matched[0]
outer_radii = _loop_radius_candidates(outer_loop, entities)
outer_radius = max(outer_radii) if outer_radii else 0.0
outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0
has_partial_cap = False
for face in operation.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
area_m2 = face.get("area_m2")
if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0:
face_area = abs(float(area_m2)) * 1_000_000
if face_area < outer_disk_area * 0.9:
has_partial_cap = True
break
if has_partial_cap:
inner_candidates = [
loop for loop in loops
if loop is not outer_loop
and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4)
]
if inner_candidates:
inner = max(
(
loop
for loop in inner_candidates
if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5
),
key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]),
default=None,
)
if inner is None:
return owned_matched
inner_radii = _loop_radius_candidates(inner, entities)
inner_radius = max(inner_radii) if inner_radii else 0.0
if inner_radius <= 0:
return owned_matched
outer_copy = dict(outer_loop)
inner_copy = dict(inner)
outer_copy["profile_mode"] = "add"
inner_copy["profile_mode"] = "subtract"
return [outer_copy, inner_copy]
return owned_matched
annotated = []
for loop in loops:
bbox = loop.get("bbox_mm")
area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox))
containers = [
outer for outer in loops
if outer is not loop
and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4)
and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05
]
loop_copy = dict(loop)
loop_copy["profile_mode"] = "subtract" if containers else "add"
annotated.append(loop_copy)
return annotated
return loops
def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]:
import math
name = sketch.get("name", "Sketch")
op_type = operation.get("type") if isinstance(operation, dict) else None
workplane = sketch.get("workplane", {})
entities = sketch.get("entities", [])
loops = _active_profile_loops(sketch, operation)
workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops)
code = [f" # Sketch: {name}"]
origin = workplane.get("origin_mm", [0, 0, 0])
x_dir = workplane.get("x_dir", [1, 0, 0])
normal = workplane.get("normal", [0, 0, 1])
if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]:
code.append(
f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:"
)
else:
code.append(" with BuildSketch() as sketch:")
loop_entities = []
processed_indices = set()
for loop in loops:
for idx in loop.get("entity_indices", []):
if idx < len(entities):
loop_entities.append(entities[idx])
processed_indices.add(idx)
append_unprocessed = not loops
for i, ent in enumerate(entities):
if append_unprocessed and i not in processed_indices:
loop_entities.append(ent)
drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)]
circle_entities = [
ent for ent in drawable_entities
if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle")
]
wire_entities = [
ent for ent in drawable_entities
if ent not in circle_entities and ent.get("type") in ("line", "arc")
]
wire_entities = _ordered_wire_entities(wire_entities)
handled_circle_entities = set()
if not loops and len(circle_entities) > 1:
ranked_circles = sorted(
enumerate(circle_entities),
key=lambda item: float(item[1].get("radius_mm", 0) or 0),
reverse=True,
)
outer_index, outer = ranked_circles[0]
outer_center = outer.get("center", [0, 0, 0])
outer_radius = float(outer.get("radius_mm", 0) or 0)
contains_all = outer_radius > 0
for _, inner in ranked_circles[1:]:
inner_center = inner.get("center", [0, 0, 0])
inner_radius = float(inner.get("radius_mm", 0) or 0)
center_distance = math.hypot(
float(inner_center[0]) - float(outer_center[0]),
float(inner_center[1]) - float(outer_center[1]),
)
if center_distance + inner_radius >= outer_radius - 1e-6:
contains_all = False
break
if contains_all:
code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):")
code.append(f" Circle({outer_radius})")
handled_circle_entities.add(outer_index)
for inner_index, inner in ranked_circles[1:]:
center = inner.get("center", [0, 0, 0])
radius = inner.get("radius_mm", 1)
code.append(f" with Locations(({center[0]}, {center[1]})):")
code.append(f" Circle({radius}, mode=Mode.SUBTRACT)")
handled_circle_entities.add(inner_index)
def circle_is_inner_profile(ent: Dict[str, Any]) -> bool:
if op_type != "extrude_add" or not loops:
return False
center = ent.get("center", [0, 0])
radius = float(ent.get("radius_mm", 0) or 0)
if radius <= 0 or len(center) < 2:
return False
bbox = [
float(center[0]) - radius,
float(center[1]) - radius,
float(center[0]) + radius,
float(center[1]) + radius,
]
return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops)
if not loops:
for circle_index, ent in enumerate(circle_entities):
if circle_index in handled_circle_entities:
continue
center = ent.get("center", [0, 0, 0])
radius = ent.get("radius_mm", 1)
code.append(f" with Locations(({center[0]}, {center[1]})):")
if circle_is_inner_profile(ent):
code.append(f" Circle({radius}, mode=Mode.SUBTRACT)")
else:
code.append(f" Circle({radius})")
def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
"""Orient contour segments into a continuous closed wire.
SolidWorks contour arrays preserve membership but not necessarily each
segment's traversal direction. Reversing an arc must also invert its
curve axis; otherwise a short arc becomes its 270-degree complement.
"""
segments = [deepcopy(entity) for entity in profile_entities]
if len(segments) < 2:
return segments
def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]:
start = entity.get("start")
end = entity.get("end")
if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2):
return None, None
return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])]
def distance(left: list[float], right: list[float]) -> float:
return math.hypot(left[0] - right[0], left[1] - right[1])
def reverse(entity: Dict[str, Any]) -> Dict[str, Any]:
reversed_entity = deepcopy(entity)
reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start")
axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis")
if isinstance(axis, list) and len(axis) >= 3:
reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]]
# 删除预置角度,强制代码生成时从翻转后的start/end重新计算
reversed_entity.pop("start_angle_deg", None)
reversed_entity.pop("end_angle_deg", None)
reversed_entity.pop("arc_sweep_deg", None)
if entity.get("type") == "arc":
center = entity.get("center") or [0.0, 0.0]
start = reversed_entity.get("start") or [0.0, 0.0]
end = reversed_entity.get("end") or [0.0, 0.0]
start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0])))
end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0])))
reversed_sweep = end_angle - start_angle
if reversed_sweep <= -180:
reversed_sweep += 360
elif reversed_sweep > 180:
reversed_sweep -= 360
reversed_entity["arc_sweep_deg"] = reversed_sweep
return reversed_entity
ordered = [segments.pop(0)]
while segments:
_, previous_end = endpoints(ordered[-1])
if previous_end is None:
ordered.extend(segments)
break
candidates = []
for index, candidate in enumerate(segments):
candidate_start, candidate_end = endpoints(candidate)
if candidate_start is None or candidate_end is None:
continue
candidates.append((distance(previous_end, candidate_start), index, candidate))
candidates.append((distance(previous_end, candidate_end), index, reverse(candidate)))
if not candidates:
ordered.extend(segments)
break
_, selected_index, selected = min(candidates, key=lambda item: item[0])
ordered.append(selected)
segments.pop(selected_index)
return ordered
def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None:
profile_entities = orient_wire_entities(profile_entities)
code.append(" with BuildLine():")
code.append(" pass")
emitted_wire = False
line_points = []
for line_ent in profile_entities:
if line_ent.get("type") == "line":
line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])])
line_bbox = None
if line_points:
xs = [float(point[0]) for point in line_points]
ys = [float(point[1]) for point in line_points]
line_bbox = (min(xs), min(ys), max(xs), max(ys))
for ent in profile_entities:
ent_type = ent.get("type", "")
if ent_type == "line":
start = ent.get("start", [0, 0, 0])
end = ent.get("end", [0, 0, 0])
if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6:
code.append(" # Skip zero-length line")
continue
code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))")
emitted_wire = True
elif ent_type == "arc":
center = ent.get("center", [0, 0, 0])
radius = ent.get("radius_mm", 1)
if "start_angle_deg" in ent and "end_angle_deg" in ent:
start_angle = ent["start_angle_deg"]
end_angle = ent["end_angle_deg"]
else:
start = ent.get("start", [0, 0])
end = ent.get("end", [0, 0])
start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0]))
end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0]))
if ent.get("arc_sweep_deg") is not None:
arc_size = float(ent["arc_sweep_deg"])
else:
curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis")
if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9:
if float(curve_axis[2]) >= 0:
arc_size = (end_angle - start_angle) % 360
else:
arc_size = -((start_angle - end_angle) % 360)
else:
arc_size = end_angle - start_angle
if arc_size <= 0:
arc_size += 360
if arc_size > 180:
arc_size -= 360
code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})")
emitted_wire = True
else:
code.append(f" # TODO: entity type {ent_type}")
if not emitted_wire:
code.append(" # Skip empty wire profile")
return
if make_face_mode:
code.append(f" make_face(mode=Mode.{make_face_mode.upper()})")
else:
code.append(" make_face()")
if loops:
ordered_loops = sorted(
enumerate(loops),
key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]),
)
for loop_order_index, (loop_index, loop) in enumerate(ordered_loops):
profile_entities = [
entities[idx]
for idx in loop.get("entity_indices", [])
if idx < len(entities)
and not entities[idx].get("construction", False)
and entities[idx].get("type") in ("line", "arc", "circle")
]
circle_profile_entities = [
ent for ent in profile_entities
if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle"))
]
wire_profile_entities = [
ent for ent in profile_entities
if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities
]
wire_profile_entities = _ordered_wire_entities(wire_profile_entities)
if not profile_entities:
continue
mode = loop.get("profile_mode")
if wire_profile_entities:
append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None)
else:
for ent in circle_profile_entities:
center = ent.get("center", [0, 0, 0])
radius = ent.get("radius_mm", 1)
code.append(f" with Locations(({center[0]}, {center[1]})):")
if mode == "subtract":
code.append(f" Circle({radius}, mode=Mode.SUBTRACT)")
else:
code.append(f" Circle({radius})")
elif wire_entities:
append_wire_profile(wire_entities)
return code
def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]:
if not isinstance(sketch, dict):
return []
radii = []
for entity in sketch.get("entities", []) or []:
if entity.get("construction") or entity.get("type") != "circle":
continue
radius = float(entity.get("radius_mm") or 0)
if radius > 0:
radii.append(abs(radius))
return radii
def _flip_side_step_inner_radius_mm(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]],
operations: list[Dict[str, Any]],
sketches: Dict[str, Dict[str, Any]],
) -> Optional[float]:
outer_radii = _sketch_circle_radii_mm(sketch)
if not outer_radii:
return None
outer = max(outer_radii)
if len(outer_radii) > 1:
return min(outer_radii)
inner = None
try:
op_index = operations.index(op)
except ValueError:
op_index = len(operations)
for prev in operations[:op_index]:
if prev.get("type") != "extrude_cut":
continue
if not (prev.get("parameters") or {}).get("flip_side_to_cut"):
continue
prev_sketch = sketches.get(prev.get("sketch") or "", {})
for radius in _sketch_circle_radii_mm(prev_sketch):
if radius < outer - 1e-6:
inner = radius if inner is None else max(inner, radius)
return inner
def _flip_side_uses_step_ring(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]],
operations: list[Dict[str, Any]],
sketches: Dict[str, Dict[str, Any]],
) -> tuple[Optional[float], Optional[float]]:
outer_radii = _sketch_circle_radii_mm(sketch)
if not outer_radii:
return None, None
outer = max(outer_radii)
inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches)
if inner is None or outer <= inner + 0.5:
return None, None
if outer < 35 and outer / inner < 1.5:
return None, None
return outer, inner
def _effective_extrude_cut_depth_mm(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]],
distance_mm: float,
) -> float:
params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {}
if not params.get("flip_side_to_cut"):
return distance_mm
workplane = (sketch or {}).get("workplane") or {}
origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0]
normal = workplane.get("normal") or [0.0, 0.0, 1.0]
if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3:
return distance_mm
axis = max(range(3), key=lambda idx: abs(float(normal[idx])))
cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm)
cut_sign = -1.0 if cut_amount < 0 else 1.0
owned_values = []
for face in op.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
if not surface.get("is_plane"):
continue
box = face.get("box_m")
if not isinstance(box, list) or len(box) < 6:
continue
thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)]
if min(thicknesses) > 0.5:
continue
owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000])
if not owned_values:
return distance_mm
transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0
effective = abs(float(origin[axis]) - transition)
if effective <= 1e-6:
return distance_mm
if abs(effective - abs(distance_mm)) <= 0.25:
return distance_mm
# Guard: owned-face depth can be wrong when all owned faces
# are near the sketch plane (e.g., edge details), not at the
# real cut termination. Fall back to a through-cut distance
# so the invert-cutter extends past the entire body.
if effective < max(2.0, abs(distance_mm) * 0.15):
return max(distance_mm, THROUGH_CUT_AMOUNT_MM)
return effective
def _owned_extrude_terminal_offsets_mm(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]],
) -> tuple[Optional[float], Optional[float]]:
"""Return the nearest owned planar end faces along the sketch normal.
SolidWorks can report a two-sided feature with a stale blind depth when one
side terminates on geometry. The feature-owned end face is the reliable
result geometry: its signed offset from the sketch plane identifies the
actual termination direction and distance.
"""
workplane = (sketch or {}).get("workplane") or {}
origin = workplane.get("origin_mm") or []
normal = workplane.get("normal") or []
if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3):
return None, None
magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3]))
if magnitude <= 1e-9:
return None, None
unit_normal = [float(value) / magnitude for value in normal[:3]]
positive: list[float] = []
negative: list[float] = []
for face in op.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
if not surface.get("is_plane"):
continue
params = surface.get("plane_params")
if not isinstance(params, list) or len(params) < 6:
continue
point_mm = [float(value) * 1000 for value in params[3:6]]
offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3))
if offset > 1e-4:
positive.append(offset)
elif offset < -1e-4:
negative.append(offset)
return (max(positive) if positive else None, min(negative) if negative else None)
def _resolve_extrude_owned_termination(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""Resolve an asymmetric two-sided add from its SolidWorks-owned end face."""
params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {}
if op.get("type") != "extrude_add" or not params.get("both_directions"):
return op
positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch)
if (positive is None) == (negative is None):
return op
resolved = dict(op)
resolved_params = dict(params)
resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative))
resolved_params["reverse_distance_mm"] = 0
resolved_params["both_directions"] = False
resolved_params["reverse_direction"] = negative is not None
resolved_params["owned_termination_resolved"] = True
resolved["parameters"] = resolved_params
return resolved
def _generate_extrude(
op: Dict[str, Any],
sketch: Optional[Dict[str, Any]] = None,
operations: Optional[list[Dict[str, Any]]] = None,
sketches: Optional[Dict[str, Dict[str, Any]]] = None,
) -> list[str]:
params = op.get("parameters", {})
distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10))
reverse_distance = params.get("reverse_distance_mm", 0)
op_type = op.get("type", "")
name = op.get("name", "")
both_directions = params.get("both_directions", False)
flip_side_to_cut = bool(params.get("flip_side_to_cut", False))
end_condition_code = params.get("end_condition_code")
reverse_end_condition_code = params.get("reverse_end_condition_code")
end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})")
operations = operations or []
sketches = sketches or {}
outer_radius, inner_radius = (
_flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None)
)
resolved_owned_termination = bool(params.get("owned_termination_resolved"))
code = [f" # {op_type}: {name}"]
if resolved_owned_termination:
code.append(" # Use the owned planar end face to resolve SW's asymmetric termination")
preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add"
if end_condition_code is not None:
code.append(f" # SW end condition: {end_condition}")
owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {})
prefer_blind_sketch = _prefer_blind_sketch_extrude(
op, sketch or {}, distance, end_condition_code, owned_cylinder_faces
)
if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut:
code.append(" # Replay SW flip-side circular cut from owned cylindrical faces")
code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})")
return code
if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch:
code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing")
code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})")
return code
owned_bbox = _owned_bbox_cut(op, sketch or {}, distance)
if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch:
code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing")
code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})")
return code
if distance == 0 and reverse_distance == 0:
if op_type == "extrude_cut" and end_condition_code not in (None, 0):
distance = THROUGH_CUT_AMOUNT_MM
both_directions = end_condition_code in (1, 2, 9)
code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter")
else:
code.append(" # Skip: zero distance")
return code
elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)):
code.append(" # SW mid-plane/two-sided extrusion represented by this IR")
distance = distance / 2
reverse_distance = distance
both_directions = True
elif op_type == "extrude_cut" and end_condition_code not in (None, 0):
distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM)
both_directions = both_directions or end_condition_code in (1, 2, 9)
code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter")
if both_directions:
amount = max(distance, reverse_distance) if reverse_distance > 0 else distance
if op_type == "extrude_cut":
code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)")
if flip_side_to_cut:
normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1])
if outer_radius is not None and inner_radius is not None:
code.append(
" result = sw_flip_side_step_cut("
f"result, cutter, normal={_tuple3(normal)}, "
f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})"
)
else:
code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})")
else:
code.append(" result = safe_subtract(result, cutter)")
else:
code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)")
code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})")
elif op_type == "extrude_cut":
if distance > 0:
cut_amount = distance if params.get("reverse_direction", False) else -distance
code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})")
# 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置
if prefer_blind_sketch:
face_offset = _blind_extrude_face_offset(op, sketch or {})
if face_offset is not None:
code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))")
if flip_side_to_cut:
normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1])
if outer_radius is not None and inner_radius is not None:
code.append(
" result = sw_flip_side_step_cut("
f"result, cutter, normal={_tuple3(normal)}, "
f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})"
)
else:
code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})")
else:
code.append(" result = safe_subtract(result, cutter)")
else:
code.append(" # Skip: zero distance cut")
else:
add_amount = -distance if params.get("reverse_direction", False) else distance
code.append(f" solid = extrude(sketch.sketch, amount={add_amount})")
code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})")
return code
def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]:
if op.get("type") != "extrude_cut":
return []
sketch_radii = [
abs(float(entity.get("radius_mm") or 0))
for entity in sketch.get("entities", []) or []
if not entity.get("construction") and entity.get("type") == "circle"
]
if not sketch_radii:
return []
matched = []
for face in op.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
params = surface.get("cylinder_params")
bbox = face.get("box_m")
if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7):
continue
if not (isinstance(bbox, list) and len(bbox) >= 6):
continue
radius_mm = abs(float(params[6]) * 1000)
if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii):
continue
matched.append(face)
return matched
def _blind_extrude_face_offset(
op: Dict[str, Any],
sketch: Dict[str, Any],
) -> Optional[list[float]]:
"""当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。
返回None表示不需要平移。"""
faces = (op.get("source_owned_faces") or [])
if not faces:
return None
valid_bboxes = []
for face in faces:
bm = face.get("box_m")
if isinstance(bm, list) and len(bm) >= 6:
valid_bboxes.append([float(v) * 1000 for v in bm[:6]])
if not valid_bboxes:
return None
normal = (sketch.get("workplane") or {}).get("normal")
if not isinstance(normal, list) or len(normal) < 3:
return None
origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0]
# 确定主导轴 (extrude方向)
axis = max(range(3), key=lambda idx: abs(float(normal[idx])))
normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0
sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0
# 取离草图平面最近的面坐标,使cutter从面的最近点开始切入
# 对于多个面,面可能在草图平面两侧。
all_coords = []
for b in valid_bboxes:
all_coords.append(b[axis])
all_coords.append(b[axis + 3])
if not all_coords:
return None
# 找离sketch_coord最近的面坐标
face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord))
offset = face_coord - sketch_coord
if abs(offset) < 1e-3:
return None
# 返回3D平移向量(仅沿extrude方向)
result = [0.0, 0.0, 0.0]
result[axis] = offset
return result
def _prefer_blind_sketch_extrude(
op: Dict[str, Any],
sketch: Dict[str, Any],
distance_mm: float,
end_condition_code: Optional[int],
owned_cylinder_faces: list[Dict[str, Any]],
) -> bool:
"""优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面,
盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题
产生意外偏差,此时仍走 bbox 路径。"""
if owned_cylinder_faces:
return False
if end_condition_code not in (None, 0) or distance_mm <= 0:
return False
if not _sketch_has_buildable_profile(sketch):
return False
# 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确
entities = sketch.get("entities", []) or []
non_const = [e for e in entities if not e.get("construction", False)]
types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")}
# 排除point/text后仍是简单截面才用盲拉伸。
# 但如果面位于不同平面,让_blind_extrude_face_offset处理
is_simple = types <= {"line"} or types <= {"circle"}
if not is_simple:
return False
# 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用
faces = op.get("source_owned_faces") or []
if faces and _blind_extrude_face_offset(op, sketch) is not None:
return True # 有面偏移,需要盲拉伸+offset修正
# 无面偏移时,只有当start/end引用完整时才用盲拉伸
if op.get("start_reference") or op.get("end_reference"):
return True
return False
def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]:
if op.get("type") != "extrude_cut":
return None
faces = [
face for face in (op.get("source_owned_faces") or [])
if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6
]
if not faces:
return None
bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces]
bbox = [
min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes)
for axis in range(6)
]
normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1]
if not isinstance(normal, list) or len(normal) < 3:
return None
axis = max(range(3), key=lambda idx: abs(float(normal[idx])))
extent = abs(bbox[axis + 3] - bbox[axis])
origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0]
origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None
distance = abs(float(distance_mm or 0))
origin_outside = (
origin_coord is not None
and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6)
)
if extent <= distance * 1.25 and not origin_outside:
return None
return bbox
def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]:
params = op.get("parameters", {})
angle = params.get("angle_deg")
if angle is None and params.get("angle_rad") is not None:
angle = float(params.get("angle_rad")) * 180 / math.pi
if angle is None:
angle = 360
if abs(angle - 360) < 1e-6:
angle = 360
op_type = op.get("type", "")
name = op.get("name", "")
code = [f" # {op_type}: {name}"]
axis_expr = _revolve_axis_expr(params, sketch or {})
code.append(f" revolve_axis = {axis_expr}")
if op_type == "revolve_cut":
code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})")
code.append(" # Force OCCT to fully evaluate both solids before Boolean ops")
code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume")
code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume")
code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)")
code.append(" result = safe_subtract(result, cutter)")
else:
code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})")
preserve_visible = bool(op.get("source_owned_faces"))
code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})")
return code
def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str:
# 优先使用草图中的构造线作为旋转轴,
# 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此)
construction_axis = _sketch_construction_axis(sketch)
if construction_axis:
origin, direction = construction_axis
return f"Axis({_tuple3(origin)}, {_tuple3(direction)})"
axis_reference = params.get("axis_reference") or {}
if axis_reference.get("origin_mm") and axis_reference.get("direction"):
return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})"
for candidate in params.get("axis_candidates") or []:
if candidate.get("model_start_mm") and candidate.get("model_direction"):
return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})"
workplane = sketch.get("workplane", {})
origin = workplane.get("origin_mm", [0, 0, 0])
direction = workplane.get("x_dir", [1, 0, 0])
return f"Axis({_tuple3(origin)}, {_tuple3(direction)})"
def _sketch_construction_axis(
sketch: Dict[str, Any],
) -> Optional[tuple[list[float], list[float]]]:
workplane = sketch.get("workplane", {})
origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])]
x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])]
y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])]
for entity in sketch.get("entities", []):
if entity.get("type") != "line" or not entity.get("construction"):
continue
start = entity.get("start")
end = entity.get("end")
if not start or not end:
continue
start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start)
end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end)
direction = [end_3d[i] - start_3d[i] for i in range(3)]
length = math.sqrt(sum(component * component for component in direction))
if length <= 0:
continue
return start_3d, [component / length for component in direction]
return None
def _sketch_point_to_model_from_basis(
origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float]
) -> list[float]:
return [
origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1])
for i in range(3)
]
def _generate_fillet(op: Dict[str, Any]) -> list[str]:
params = op.get("parameters", {})
radius = params.get("radius_mm")
selectors = op.get("selectors", [])
owned_faces = op.get("source_owned_faces") or []
if not radius or float(radius) <= 0:
return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"]
return [
f" # Fillet: {op.get('name', '')}",
" result = fillet_selected("
f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})",
]
def _generate_chamfer(op: Dict[str, Any]) -> list[str]:
params = op.get("parameters", {})
distance = params.get("distance_mm")
selectors = op.get("selectors", [])
owned_faces = op.get("source_owned_faces") or []
if not distance or float(distance) <= 0:
return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"]
return [
f" # Chamfer: {op.get('name', '')}",
" result = chamfer_selected_with_owned_faces("
f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})",
]
def _generate_move_face(op: Dict[str, Any]) -> list[str]:
data = (op.get("parameters") or {}).get("move_face_data") or {}
selected_faces = data.get("selected_faces") or []
return [
f" # MoveFace pure-JSON operation: {op.get('name', '')}",
" raise NotImplementedError(",
f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'",
" )",
]
def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool:
positions = params.get("positions") or []
diameter = _hole_diameter_mm(params)
if not positions or diameter <= 0:
return False
if len(owned_cut_faces) <= 1:
return False
has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces)
drill_angle = _hole_drill_angle_rad(params)
if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0):
return False
counterbore_diameter = _hole_counterbore_diameter_mm(params)
counterbore_depth = _hole_counterbore_depth_mm(params)
if counterbore_diameter > diameter and counterbore_depth > 0:
return True
return _hole_has_through_dimension(params)
def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float:
if _hole_has_through_dimension(params):
return THROUGH_CUT_AMOUNT_MM
return _hole_depth_mm(params)
def _generate_hole(op: Dict[str, Any]) -> list[str]:
params = op.get("parameters", {})
diameter = _hole_diameter_mm(params)
depth = _effective_hole_cut_depth_mm(params)
drill_angle = _hole_drill_angle_rad(params)
include_drill_tip = _hole_has_drill_tip(params)
countersink_diameter = _hole_countersink_diameter_mm(params)
countersink_angle = _hole_countersink_angle_rad(params)
counterbore_diameter = _hole_counterbore_diameter_mm(params)
counterbore_depth = _hole_counterbore_depth_mm(params)
positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")]
host_face = params.get("host_face") or {}
owned_cut_faces = _hole_owned_cut_faces(op)
# Feature position sketches are occasionally incomplete in the plugin export
# (notably for wizard holes with multiple instances). The faces owned by the
# feature are the authoritative result from SolidWorks, including every hole
# location, counterbore, countersink, and drill tip. Prefer replaying those
# surfaces whenever they are available; fall back to the parametric cutter
# only when the exporter has no usable owned-face geometry.
if owned_cut_faces:
return [
f" # Hole: {op.get('name', '')}",
" # Replay hole from SW owned cut faces to preserve side and axis",
f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})",
]
return [
f" # Hole: {op.get('name', '')}",
f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})",
]
def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]:
matched = []
for face in op.get("source_owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
bbox = face.get("box_m")
has_cylinder = (
surface.get("is_cylinder")
and isinstance(surface.get("cylinder_params"), list)
and len(surface.get("cylinder_params") or []) >= 7
)
has_cone = (
surface.get("is_cone")
and isinstance(surface.get("cone_params"), list)
and len(surface.get("cone_params") or []) >= 8
)
if not (has_cylinder or has_cone):
continue
if not (isinstance(bbox, list) and len(bbox) >= 6):
continue
matched.append(face)
return matched
def _generate_linear_pattern(
op: Dict[str, Any],
operations: list[Dict[str, Any]],
sketches: Dict[str, Dict[str, Any]],
references: Dict[str, Any],
) -> list[str]:
params = op.get("parameters", {})
source_features = params.get("source_features") or []
offsets = _linear_pattern_offsets(op)
code = [f" # Linear pattern: {op.get('name', '')}"]
if not source_features or not offsets:
code.append(" # Skip: no source features or pattern offsets")
return code
for source_feature in source_features:
source_op = _find_operation_for_source_feature(operations, source_feature)
if not source_op:
code.append(f" # Skip: source feature not found {source_feature.get('name')}")
continue
for offset_index, offset in enumerate(offsets, start=1):
copied_op = _translated_operation(source_op, offset)
copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}"
op_type = copied_op.get("type")
if op_type == "hole":
code.extend(_generate_hole(copied_op))
elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"):
source_sketch_id = copied_op.get("sketch")
source_sketch = sketches.get(source_sketch_id or "")
if not source_sketch:
code.append(f" # Skip: source sketch not found for {copied_op.get('name')}")
continue
if not _sketch_has_buildable_profile(source_sketch):
code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}")
continue
copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}")
code.extend(_generate_sketch(copied_sketch, references, copied_op))
if op_type in ("extrude_cut", "extrude_add"):
code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches))
else:
code.extend(_generate_revolve(copied_op, copied_sketch))
else:
code.append(f" # TODO: pattern source type {op_type}")
return code
def _generate_mirror_pattern(
op: Dict[str, Any],
operations: list[Dict[str, Any]],
sketches: Dict[str, Dict[str, Any]],
references: Dict[str, Any],
) -> list[str]:
"""生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。"""
params = op.get("parameters", {})
source_features = params.get("source_features") or []
raw = op.get("raw_parameters", {})
mirror_plane_info = raw.get("mirror_plane") or {}
code = [f" # Mirror pattern: {op.get('name', '')}"]
plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info)
plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info)
mx = plane_origin[0] if plane_origin else 0.0
my = plane_origin[1] if plane_origin else 0.0
mz = plane_origin[2] if plane_origin else 0.0
nx = plane_normal[0] if plane_normal else 0.0
ny = plane_normal[1] if plane_normal else 0.0
nz = plane_normal[2] if plane_normal else 1.0
code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))")
code.append(f" mx, my, mz = {mx}, {my}, {mz}")
code.append(f" nx, ny, nz = {nx}, {ny}, {nz}")
code.append(f" try:")
code.append(f" bbox = result.bounding_box()")
code.append(f" margin = 10.0")
# Determine dominant axis and cut away the -normal side
adx, ady, adz = abs(nx), abs(ny), abs(nz)
if adx >= ady and adx >= adz:
if nx > 0:
code.append(f" cut_w = (mx - bbox.min.X) + margin")
code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)")
code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))")
else:
code.append(f" cut_w = (bbox.max.X - mx) + margin")
code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)")
code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))")
elif ady >= adx and ady >= adz:
if ny > 0:
code.append(f" cut_h = (my - bbox.min.Y) + margin")
code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)")
code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))")
else:
code.append(f" cut_h = (bbox.max.Y - my) + margin")
code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)")
code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))")
else:
if nz > 0:
code.append(f" cut_d = (mz - bbox.min.Z) + margin")
code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)")
code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))")
else:
code.append(f" cut_d = (bbox.max.Z - mz) + margin")
code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)")
code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))")
code.append(f" half = result.cut(cut_box)")
code.append(f" mirrored = half.mirror(mirror_plane)")
code.append(f" result = half.fuse(mirrored).clean()")
code.append(f" except Exception as e:")
code.append(f" print(f'mirror failed: {{e}}')")
return code
def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict):
mir_origin = raw.get("mirror_plane_origin")
if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3:
return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2]))
origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or []
if origin_list and len(origin_list) >= 3:
return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2]))
frame = mirror_plane_info.get("frame")
if isinstance(frame, dict):
origin_list = frame.get("origin") or []
if origin_list and len(origin_list) >= 3:
return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2]))
return None
def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict):
mir_normal = raw.get("mirror_plane_normal")
if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3:
return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2]))
normal_list = mirror_plane_info.get("normal") or []
if normal_list and len(normal_list) >= 3:
return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2]))
frame = mirror_plane_info.get("frame")
if isinstance(frame, dict):
normal_list = frame.get("normal") or []
if normal_list and len(normal_list) >= 3:
return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2]))
return None
def _find_operation_for_source_feature(
operations: list[Dict[str, Any]], source_feature: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
source_index = source_feature.get("index")
source_name = source_feature.get("name")
source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {}
source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id")
source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference")
for op in operations:
op_source = op.get("source_feature", {})
if source_index is not None and op_source.get("index") == source_index:
return op
for op in operations:
op_source = op.get("source_feature", {})
op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {}
if source_stable_id and (
op_source.get("stable_id") == source_stable_id
or op_identity.get("stable_id") == source_stable_id
):
return op
if source_persistent_reference and (
op_source.get("persistent_reference") == source_persistent_reference
or op_identity.get("persistent_reference") == source_persistent_reference
):
return op
for op in operations:
if source_name and op.get("name") == source_name:
return op
return None
def _find_source_operation_for_pattern(
operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
for source_feature in source_features:
source_op = _find_operation_for_source_feature(operations, source_feature)
if source_op:
return source_op
return None
def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]:
params = op.get("parameters", {})
raw = op.get("raw_parameters", {})
explicit_offsets = raw.get("explicit_offsets_mm")
if isinstance(explicit_offsets, list) and explicit_offsets:
return [
(float(offset[0]), float(offset[1]), float(offset[2]))
for offset in explicit_offsets
if isinstance(offset, list) and len(offset) >= 3
]
d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1)
d2_count = int(raw.get("d2_total_instances") or 1)
d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0)
d2_spacing = float(raw.get("d2_spacing_mm") or 0)
d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing)
d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing)
offsets = []
for i in range(d1_count):
for j in range(d2_count):
if i == 0 and j == 0:
continue
offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3)))
return offsets
def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]:
if not direction or not spacing:
return (0.0, 0.0, 0.0)
direct_vector = direction.get("vector")
if isinstance(direct_vector, list) and len(direct_vector) >= 3:
vector = tuple(float(direct_vector[i]) for i in range(3))
length = math.sqrt(sum(component * component for component in vector))
if length <= 0:
return (0.0, 0.0, 0.0)
return tuple(component / length * spacing for component in vector)
start = direction.get("start", {}).get("mm")
end = direction.get("end", {}).get("mm")
if not start or not end:
return (0.0, 0.0, 0.0)
vector = tuple(float(end[i]) - float(start[i]) for i in range(3))
length = math.sqrt(sum(component * component for component in vector))
if length <= 0:
return (0.0, 0.0, 0.0)
return tuple(component / length * spacing for component in vector)
def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]:
copied = deepcopy(op)
params = copied.get("parameters") or {}
axis_reference = params.get("axis_reference")
if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list):
origin = list(axis_reference.get("origin_mm") or [0, 0, 0])
origin = (origin + [0, 0, 0])[:3]
axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)]
if copied.get("type") == "hole":
positions = params.get("positions") or []
local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {})
for position in positions:
if position.get("mm"):
point = list(position.get("mm") or [0, 0, 0])
point = (point + [0, 0, 0])[:3]
position["mm"] = [
float(point[0]) + local_offset[0],
float(point[1]) + local_offset[1],
float(point[2]) + local_offset[2],
]
if position.get("m"):
position["m"] = [value / 1000 for value in position.get("mm", [])]
if any(abs(float(offset[i])) > 1e-9 for i in range(3)):
owned_faces = copied.get("source_owned_faces") or []
if owned_faces:
copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset)
return copied
def _translate_owned_faces(
faces: list[Dict[str, Any]],
offset: tuple[float, float, float],
) -> list[Dict[str, Any]]:
translated = []
shift_mm = (float(offset[0]), float(offset[1]), float(offset[2]))
shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0)
for face in faces:
if not isinstance(face, dict):
continue
copied = deepcopy(face)
box = copied.get("box_m")
if isinstance(box, list) and len(box) >= 6:
copied["box_m"] = [
float(box[0]) + shift_m[0],
float(box[1]) + shift_m[1],
float(box[2]) + shift_m[2],
float(box[3]) + shift_m[0],
float(box[4]) + shift_m[1],
float(box[5]) + shift_m[2],
]
surface = copied.get("surface")
if isinstance(surface, dict):
for key in ("cylinder_params", "cone_params"):
params = surface.get(key)
if isinstance(params, list) and len(params) >= 3:
updated = list(params)
updated[0] = float(updated[0]) + shift_m[0]
updated[1] = float(updated[1]) + shift_m[1]
updated[2] = float(updated[2]) + shift_m[2]
surface[key] = updated
translated.append(copied)
return translated
def _model_offset_to_host_local(
offset: tuple[float, float, float],
host_face: Dict[str, Any],
) -> tuple[float, float, float]:
frame = host_face.get("frame") if isinstance(host_face, dict) else {}
if not isinstance(frame, dict):
return offset
x_dir = frame.get("x_dir")
y_dir = frame.get("y_dir")
if not (
isinstance(x_dir, list)
and len(x_dir) >= 3
and isinstance(y_dir, list)
and len(y_dir) >= 3
):
return offset
local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3))
local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3))
return (local_x, local_y, 0.0)
def _translated_sketch(
sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str
) -> Dict[str, Any]:
copied = deepcopy(sketch)
copied["id"] = sketch_id
copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy"
workplane = copied.setdefault("workplane", {})
origin = list(workplane.get("origin_mm") or [0, 0, 0])
origin = (origin + [0, 0, 0])[:3]
workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)]
return copied
def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None:
dx, dy = offset[0], offset[1]
for entity in sketch.get("entities", []):
for key in ("start", "end", "center"):
point = entity.get(key)
if isinstance(point, list) and len(point) >= 2:
point[0] = float(point[0]) + dx
point[1] = float(point[1]) + dy
raw = entity.get("raw", {})
for key in ("start", "end", "center"):
raw_point = raw.get(key)
if isinstance(raw_point, dict):
mm = raw_point.get("mm")
if isinstance(mm, list) and len(mm) >= 2:
mm[0] = float(mm[0]) + dx
mm[1] = float(mm[1]) + dy
raw_point["m"] = [value / 1000 for value in mm]
def _hole_diameter_mm(params: Dict[str, Any]) -> float:
if params.get("diameter_mm"):
return float(params["diameter_mm"])
diameters = params.get("diameters_m", {})
for key in (
"hole_diameter",
"thru_hole_diameter",
"tap_drill_diameter",
"thru_tap_drill_diameter",
"thread_diameter",
"diameter",
):
value = diameters.get(key)
if value:
return float(value) * 1000
return 0
def _hole_depth_mm(params: Dict[str, Any]) -> float:
if params.get("depth_mm"):
return float(params["depth_mm"])
depths = params.get("depths_m", {})
for key in (
"hole_depth",
"thru_hole_depth",
"tap_drill_depth",
"thru_tap_drill_depth",
"thread_depth",
"depth",
):
value = depths.get(key)
if value:
return float(value) * 1000
return THROUGH_CUT_AMOUNT_MM
def _hole_drill_angle_rad(params: Dict[str, Any]) -> float:
angle = params.get("angles_rad", {}).get("drill_angle")
return float(angle) if angle else 0
def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float:
angle = params.get("angles_rad", {}).get("countersink_angle")
return float(angle) if angle else 0
def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float:
diameter = params.get("countersink_diameter_mm")
return float(diameter) if diameter else 0
def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float:
diameter = params.get("counterbore_diameter_mm")
return float(diameter) if diameter else 0
def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float:
depth = params.get("counterbore_depth_mm")
return float(depth) if depth else 0
def _hole_has_drill_tip(params: Dict[str, Any]) -> bool:
depths = params.get("depths_m", {})
angle = _hole_drill_angle_rad(params)
if angle <= 0:
return False
through_depth_keys = (
"thru_hole_depth",
"thru_tap_drill_depth",
)
if any(depths.get(key) for key in through_depth_keys):
return False
if params.get("depth_mm"):
return True
return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth"))
def _hole_has_through_dimension(params: Dict[str, Any]) -> bool:
names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or [])
return any(token in names for token in ("通孔", "through", "thru"))
def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]:
for dim in data_block.get("dimensions", []) or []:
name = str(dim.get("name") or "").lower()
if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""):
return float(dim.get("value"))
return None
def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]:
candidates: list[tuple[int, float]] = []
for dim in feature.get("dimensions") or []:
if not isinstance(dim, dict):
continue
name = str(dim.get("name") or "")
system_value = dim.get("system_value_m")
if system_value not in (None, ""):
length_mm = abs(float(system_value)) * 1000
elif dim.get("value") not in (None, ""):
length_mm = abs(float(dim.get("value")))
else:
continue
if length_mm <= 1e-9 or length_mm > 500:
continue
priority = 0 if name.startswith("D1@") else 1
candidates.append((priority, length_mm))
if not candidates:
return None
candidates.sort(key=lambda item: (item[0], item[1]))
return candidates[0][1]
def _feature_selection_selectors(
feature: Dict[str, Any],
data_block: Optional[Dict[str, Any]] = None,
) -> list[Dict[str, Any]]:
selectors: list[Dict[str, Any]] = []
seen: set[str] = set()
sources = []
if isinstance(data_block, dict):
sources.extend(data_block.get("selections") or [])
sources.extend(feature.get("selections") or [])
for selection in sources:
if not isinstance(selection, dict) or selection.get("kind") != "selection":
continue
geometry = selection.get("object")
if not isinstance(geometry, dict):
continue
kind = geometry.get("kind")
if kind not in ("edge", "face"):
continue
identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {}
stable_key = (
geometry.get("stable_id")
or geometry.get("persistent_reference")
or identity.get("stable_id")
or identity.get("persistent_reference")
or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str)
)
if stable_key in seen:
continue
seen.add(str(stable_key))
selectors.append({
"kind": kind,
"geometry": geometry,
"mark": selection.get("mark"),
"source_feature": {
"name": selection.get("feature_name"),
"type_name": selection.get("feature_type_name"),
},
})
if selectors:
return selectors
for face in feature.get("owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
cylinder_params = surface.get("cylinder_params")
if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7):
continue
radius_mm = abs(float(cylinder_params[6]) * 1000)
line_params = [
float(cylinder_params[0]),
float(cylinder_params[1]),
float(cylinder_params[2]),
float(cylinder_params[3]),
float(cylinder_params[4]),
float(cylinder_params[5]),
]
stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}"
if stable_key in seen:
continue
seen.add(stable_key)
selectors.append({
"kind": "edge",
"geometry": {
"kind": "edge",
"curve": {
"kind": "curve",
"is_line": True,
"line_params": line_params,
},
"bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]]
if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6
else None,
},
"tolerance_mm": max(0.5, radius_mm * 2.5),
"source": "owned_cylindrical_face_axis",
})
for face in feature.get("owned_faces") or []:
if not isinstance(face, dict):
continue
box_m = face.get("box_m")
if not (isinstance(box_m, list) and len(box_m) >= 6):
continue
bbox_mm = [float(value) * 1000 for value in box_m[:6]]
if any(not math.isfinite(value) for value in bbox_mm):
continue
sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)]
stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}"
if stable_key in seen:
continue
seen.add(stable_key)
selectors.append({
"kind": "edge",
"geometry": {
"kind": "edge",
"bbox_mm": bbox_mm,
},
"tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35),
"source": "owned_face_bbox",
})
return selectors
def _feature_selection_source(
feature: Dict[str, Any],
data_block: Optional[Dict[str, Any]] = None,
) -> str:
sources = []
if isinstance(data_block, dict):
sources.extend(data_block.get("selections") or [])
sources.extend(feature.get("selections") or [])
if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources):
return "solidworks_original_selection"
if feature.get("owned_faces"):
return "post_feature_owned_face_inference"
return "missing"
def _hole_dimension_value_excluding(
data_block: Dict[str, Any],
tokens: tuple[str, ...],
excluded: tuple[str, ...] = (),
) -> Optional[float]:
for dim in data_block.get("dimensions", []) or []:
name = str(dim.get("name") or "").lower()
if excluded and any(token.lower() in name for token in excluded):
continue
if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""):
return float(dim.get("value"))
return None
def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]:
values = []
for dim in data_block.get("dimensions", []) or []:
name = str(dim.get("name") or "").lower()
if not any(token in name for token in ("孔", "hole", "螺", "thread")):
continue
if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")):
continue
value = dim.get("value")
if value in (None, ""):
continue
number = abs(float(value))
if 0 < number < 200:
values.append(number)
if not values:
return None
return min(values) if prefer_small else max(values)
def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float:
diameter = (
_hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle"))
or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle"))
or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度"))
or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度"))
or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度"))
or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度"))
or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth"))
or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth"))
or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle"))
or _hole_primary_dimension_fallback(data_block, prefer_small=True)
)
return abs(float(diameter)) if diameter else 0
def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float:
depth = (
_hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径"))
or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径"))
or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度"))
or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度"))
or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle"))
or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter"))
or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter"))
)
if depth:
return abs(float(depth))
return THROUGH_CUT_AMOUNT_MM
def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]:
return (
_hole_dimension_value(data_block, ("柱形沉头", "直径"))
or _hole_dimension_value(data_block, ("柱形沉头孔", "直径"))
or _hole_dimension_value(data_block, ("沉头孔", "直径"))
or _hole_dimension_value(data_block, ("counterbore", "diameter"))
or _hole_dimension_value(data_block, ("counter", "bore", "diameter"))
)
def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]:
return (
_hole_dimension_value(data_block, ("柱形沉头", "深度"))
or _hole_dimension_value(data_block, ("柱形沉头孔", "深度"))
or _hole_dimension_value(data_block, ("沉头孔", "深度"))
or _hole_dimension_value(data_block, ("counterbore", "depth"))
or _hole_dimension_value(data_block, ("counter", "bore", "depth"))
)
def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]:
for dim in data_block.get("dimensions", []) or []:
name = str(dim.get("name") or "").lower()
if all(token.lower() in name for token in tokens):
if dim.get("system_value_m") not in (None, ""):
return float(dim.get("system_value_m"))
if dim.get("value") not in (None, ""):
value = float(dim.get("value"))
return value / 1000 if value > math.tau else value
return None
def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]:
selector_points = []
for selector in op.get("selectors", []):
geometry = selector.get("geometry") or {}
start = geometry.get("start_vertex") or {}
start_point = start.get("point_m") if isinstance(start, dict) else None
end = geometry.get("end_vertex") or {}
end_point = end.get("point_m") if isinstance(end, dict) else None
if start_point and end_point:
selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)])
return selector_points
_SW_METADATA_FEATURE_TYPES = {
"commentsfolder",
"favoritefolder",
"historyfolder",
"selectionsetfolder",
"sensorfolder",
"docsfolder",
"detailcabinet",
"surfacebodyfolder",
"solidbodyfolder",
"envfolder",
"inkmarkupfolder",
"eqnfolder",
"materialfolder",
"configtablefolder",
"ftrfolder",
}
def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]:
source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {}
identity = source.get("identity") if isinstance(source.get("identity"), dict) else {}
return {
"index": source.get("index", index),
"id": feature.get("id"),
"name": feature.get("name"),
"type": feature.get("type"),
"type_name": feature.get("type_name"),
"stable_id": source.get("stable_id") or identity.get("stable_id"),
"persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"),
"identity": identity or None,
}
def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]:
faces = feature.get("owned_faces")
if not isinstance(faces, list):
return []
summarized = []
for face in faces:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
summarized.append(
{
"box_m": face.get("box_m"),
"area_m2": face.get("area_m2"),
"surface": {
"is_plane": bool(surface.get("is_plane")),
"is_cylinder": bool(surface.get("is_cylinder")),
"is_cone": bool(surface.get("is_cone")),
"is_sphere": bool(surface.get("is_sphere")),
"is_torus": bool(surface.get("is_torus")),
"cylinder_params": surface.get("cylinder_params"),
"cone_params": surface.get("cone_params"),
"plane_params": surface.get("plane_params"),
},
}
)
return summarized
def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]:
snapshot = feature.get("definition_snapshot", {})
return {
"id": feature.get("id") or f"reference_{index:03d}",
"name": feature.get("name"),
"type": feature.get("type"),
"sw_type": feature.get("type_name"),
"definition": snapshot.get("values", {}),
"source_feature": _source_feature(feature, index),
}
def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]:
sketch_data = feature.get("sketch_data", {})
raw_entities = sketch_data.get("entities", [])
raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities]
converted_entities = []
raw_to_converted_index: dict[int, int] = {}
stable_id_to_raw_index: dict[str, int] = {}
for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)):
for stable_id in _selectable_stable_ids(raw_entity):
stable_id_to_raw_index.setdefault(stable_id, raw_index)
if converted_entity is None:
continue
raw_to_converted_index[raw_index] = len(converted_entities)
converted_entities.append(converted_entity)
loops = []
for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []:
if not isinstance(contour, dict):
continue
entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or []
if not entity_indices:
entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index)
if not entity_indices:
continue
normalized_indices = [
raw_to_converted_index[int(idx)]
for idx in entity_indices
if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index
]
if not normalized_indices:
continue
bbox = _loop_bbox([
converted_entities[idx]
for idx in normalized_indices
if 0 <= idx < len(converted_entities)
])
loops.append({
"id": contour.get("contour_id"),
"entity_indices": normalized_indices,
"is_closed": contour.get("is_closed"),
"bbox_mm": bbox or contour.get("bbox_mm"),
"bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"),
"source": "solidworks_sketch_contour",
})
workplane = sketch_data.get("workplane") or {}
if not workplane:
workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}
return {
"id": sketch_id,
"name": feature.get("name", sketch_id),
"workplane": workplane,
"host_reference": sketch_data.get("host_reference"),
"entities": converted_entities,
"loops": loops,
"sketch_regions": sketch_data.get("sketch_regions", []),
"constraints": sketch_data.get("constraints", []),
"inferred_constraints": sketch_data.get("inferred_constraints", []),
"dimensions": sketch_data.get("dimensions", []),
"feature_dimensions": sketch_data.get("feature_dimensions", []),
"source_feature": _source_feature(feature, index),
}
def _selectable_stable_ids(value: Any) -> list[str]:
if not isinstance(value, dict):
return []
candidates = [value.get("stable_id")]
identity = value.get("identity")
if isinstance(identity, dict):
candidates.append(identity.get("stable_id"))
return [str(candidate) for candidate in candidates if candidate]
def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]:
indices: list[int] = []
seen: set[int] = set()
for segment in contour.get("sketch_segments") or []:
for stable_id in _selectable_stable_ids(segment):
raw_index = stable_id_to_raw_index.get(stable_id)
if raw_index is None or raw_index in seen:
continue
seen.add(raw_index)
indices.append(raw_index)
break
return indices
def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]:
entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower()
curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {}
if (
entity_type == "circle_or_arc"
or curve.get("is_circle") is True
or entity.get("curve_entity_type") == "circle_or_arc"
):
center = entity.get("curve_center_mm") or entity.get("center_mm")
radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm")
radius_raw_value = entity.get("radius")
start = entity.get("start_mm")
end = entity.get("end_mm")
start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None
end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None
center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0]
radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0)
if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6:
# 计算 sweep 方向
import math as _math
sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0]))
ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0]))
sweep = round(ea - sa, 10)
while sweep <= -180:
sweep += 360
while sweep > 180:
sweep -= 360
result = {
"type": "arc",
"center": center_2d,
"start": start_2d,
"end": end_2d,
"radius_mm": radius_mm,
"start_angle_deg": round(sa, 10),
"end_angle_deg": round(ea, 10),
"arc_sweep_deg": round(sweep, 10),
"construction": bool(entity.get("construction")),
"raw": entity,
}
curve_axis = entity.get("curve_axis")
if isinstance(curve_axis, list) and len(curve_axis) >= 3:
result["curve_axis"] = [float(v) for v in curve_axis[:3]]
return result
return {
"type": "circle",
"center": center_2d,
"radius_mm": radius_mm,
"construction": bool(entity.get("construction")),
"raw": entity,
}
if "line" in entity_type:
return {
"type": "line",
"start": _sketch_point_mm(entity, "start"),
"end": _sketch_point_mm(entity, "end"),
"construction": bool(entity.get("construction")),
"raw": entity,
}
if "circle" in entity_type:
return {
"type": "circle",
"center": _sketch_point_mm(entity, "center"),
"radius_mm": _sketch_radius_mm(entity),
"construction": bool(entity.get("construction")),
"raw": entity,
}
if "arc" in entity_type:
return {
"type": "arc",
"center": _sketch_point_mm(entity, "center"),
"start": _sketch_point_mm(entity, "start"),
"end": _sketch_point_mm(entity, "end"),
"radius_mm": _sketch_radius_mm(entity),
"start_angle_deg": _to_degrees(entity.get("start_angle", 0)),
"end_angle_deg": _to_degrees(entity.get("end_angle", 360)),
"construction": bool(entity.get("construction")),
"raw": entity,
}
if entity_type == "point":
point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0]
return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity}
return None
def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]:
point = entity.get(f"{key}_mm")
if isinstance(point, list) and len(point) >= 2:
return [float(point[0]), float(point[1])]
return _scale_point(entity.get(key, [0, 0]))
def _sketch_radius_mm(entity: Dict[str, Any]) -> float:
for key in ("radius_mm", "major_radius_mm", "major_radius"):
if entity.get(key) is not None:
return _scale_length(entity.get(key))
if entity.get("radius") is not None:
return _scale_length(entity.get("radius"))
start = _sketch_point_mm(entity, "start")
center = _sketch_point_mm(entity, "center")
if start and center:
return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
return 1.0
def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]:
data_block = feature.get("extrude_data", {})
op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add"
distance = _best_extrude_depth_mm(feature, data_block)
reverse_end_condition_code = data_block.get("reverse_end_condition_code")
reverse_distance = abs(data_block.get("reverse_depth") or 0)
both_directions = bool(data_block.get("both_directions", False))
reverse_direction = data_block.get("is_reverse")
if reverse_direction is None:
reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection")
if reverse_direction is None:
reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False)
if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension":
spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block)
if spans_both_sides and bool(reverse_direction):
both_directions = True
reverse_distance = reverse_distance or distance
else:
both_directions = False
reverse_distance = 0
raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0)
uses_reverse_depth_only = (
op_type == "extrude_cut"
and
feature.get("type") == "ice"
and raw_depth <= 1e-9
and reverse_distance > 0
)
if uses_reverse_depth_only:
reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction)
return {
"id": feature.get("id"),
"name": feature.get("name"),
"type": op_type,
"sketch": sketch_id,
"parameters": {
"distance_mm": distance,
"reverse": bool(reverse_direction),
"reverse_direction": bool(reverse_direction),
"reverse_distance_mm": reverse_distance,
"both_directions": False if uses_reverse_depth_only else both_directions,
"end_condition": data_block.get("end_condition"),
"end_condition_code": data_block.get("end_condition_code"),
"reverse_end_condition_code": reverse_end_condition_code,
"flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)),
"start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")),
"end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")),
"reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")),
"draft_angle_rad": data_block.get("draft_angle_rad"),
"reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"),
},
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
}
def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool:
sketches = data_block.get("source_sketches") or []
workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None
if not isinstance(workplane, dict):
return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0))
origin = workplane.get("origin_mm") or [0, 0, 0]
normal = workplane.get("normal") or [0, 0, 1]
if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3:
return False
nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2]))
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
nx, ny, nz = nx / length, ny / length, nz / length
ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2])
min_distance = math.inf
max_distance = -math.inf
for face in feature.get("owned_faces") or []:
box = face.get("box_m") if isinstance(face, dict) else None
if not isinstance(box, list) or len(box) < 6:
continue
xs = [float(box[0]) * 1000, float(box[3]) * 1000]
ys = [float(box[1]) * 1000, float(box[4]) * 1000]
zs = [float(box[2]) * 1000, float(box[5]) * 1000]
for x in xs:
for y in ys:
for z in zs:
distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz
min_distance = min(min_distance, distance_to_plane)
max_distance = max(max_distance, distance_to_plane)
if math.isinf(min_distance) or math.isinf(max_distance):
return False
tolerance = 1e-4
return min_distance < -tolerance and max_distance > tolerance
def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]:
data_block = feature.get("revolve_data", {})
op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add"
selected_axis = _axis_reference_from_feature_selections(data_block.get("selections"))
owned_face_axis = _axis_reference_from_owned_faces(feature)
extracted_axis = _extract_axis_reference(data_block.get("axis_reference"))
axis_reference = selected_axis or owned_face_axis
if not axis_reference and not _is_weak_inferred_axis(extracted_axis):
axis_reference = extracted_axis
return {
"id": feature.get("id"),
"name": feature.get("name"),
"type": op_type,
"sketch": sketch_id,
"parameters": {
"angle_deg": abs(data_block.get("angle") or 360),
"angle_rad": data_block.get("angle_rad"),
"reverse": data_block.get("is_reverse", False),
"end_condition": data_block.get("end_condition"),
"end_condition_code": data_block.get("end_condition_code"),
"axis_reference": axis_reference,
"axis_candidates": data_block.get("axis_candidates", []),
},
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
}
def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]:
candidates: list[tuple[float, Dict[str, Any]]] = []
for face in feature.get("owned_faces") or []:
if not isinstance(face, dict):
continue
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
params = None
if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list):
params = surface.get("cylinder_params")
elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list):
params = surface.get("cone_params")
if not isinstance(params, list) or len(params) < 6:
continue
direction = [float(value) for value in params[3:6]]
norm = math.sqrt(sum(value * value for value in direction))
if norm <= 1e-9:
continue
candidates.append((
float(face.get("area_m2") or 0.0),
{
"origin_mm": [float(value) * 1000 for value in params[:3]],
"direction": [value / norm for value in direction],
"source": "owned_face_axis",
},
))
if not candidates:
return None
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool:
if not isinstance(axis_reference, dict):
return False
return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"}
def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]:
data_block = feature.get("hole_data", {})
positions = []
host_face = _host_face_from_feature_selections(data_block.get("selections")) or {}
position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or [])
for sketch in position_sketches:
workplane = sketch.get("workplane") or {}
if not host_face and workplane:
host_face = _host_face_from_workplane(workplane)
for point in _hole_position_points(sketch):
positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]})
diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block)
depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block)
return {
"id": feature.get("id"),
"name": feature.get("name"),
"type": "hole",
"parameters": {
"diameter_mm": diameter_mm,
"depth_mm": depth_mm,
"counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block),
"counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block),
"countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径"))
or _hole_dimension_value(data_block, ("近端锥形沉头", "直径"))
or _hole_dimension_value(data_block, ("锥坑", "直径"))
or _hole_dimension_value(data_block, ("countersink", "diameter"))
or _hole_dimension_value(data_block, ("csk", "diameter")),
"angles_rad": {
"countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度"))
or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度"))
or _hole_angle_dimension_rad(data_block, ("锥坑", "角度"))
or _hole_angle_dimension_rad(data_block, ("countersink", "angle"))
or _hole_angle_dimension_rad(data_block, ("csk", "angle")),
"drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度"))
or _hole_angle_dimension_rad(data_block, ("drill", "angle"))
or _hole_angle_dimension_rad(data_block, ("tip", "angle")),
},
"positions": positions,
"host_face": host_face,
"hole_type": data_block.get("hole_type"),
"standard": data_block.get("standard"),
"size": data_block.get("size"),
"dimension_names": [
str(dim.get("name") or "")
for dim in data_block.get("dimensions", []) or []
if isinstance(dim, dict)
],
},
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
}
def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
point_only = []
for sketch in sketches:
entities = sketch.get("entities") or []
if not entities:
continue
if _is_hole_profile_sketch(sketch):
continue
point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity))
drawable_segment_count = sum(
1
for entity in entities
if not _is_sketch_point_entity(entity) and not entity.get("construction")
)
if point_count > 0 and drawable_segment_count == 0:
point_only.append(sketch)
return point_only or sketches[:1]
def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool:
tokens = (
"孔直径",
"孔深度",
"沉头",
"导头",
"螺纹孔钻头",
"tap drill",
"drill",
"counterbore",
"countersink",
"hole diameter",
"hole depth",
)
dimension_sources = []
dimension_sources.extend(sketch.get("dimensions") or [])
dimension_sources.extend(sketch.get("feature_dimensions") or [])
for dim in dimension_sources:
if not isinstance(dim, dict):
continue
name = str(dim.get("name") or "").lower()
if any(token in name for token in tokens):
return True
return False
def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool:
entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower()
return entity_type == "point"
def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]:
raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity
candidate = raw.get("hole_position_candidate")
if candidate is None:
candidate = entity.get("hole_position_candidate")
if isinstance(candidate, bool):
candidate_flag: Optional[bool] = candidate
else:
candidate_flag = None
construction_reference = bool(
raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference")
)
return candidate_flag, construction_reference
def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]:
degrees: dict[tuple[float, float, float], int] = {}
for entity in sketch.get("entities") or []:
if not entity.get("construction"):
continue
entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower()
if "line" not in entity_type:
continue
for key in ("start_mm", "end_mm"):
endpoint = entity.get(key)
if isinstance(endpoint, list) and len(endpoint) >= 2:
point_key = _rounded_point_key(endpoint)
degrees[point_key] = degrees.get(point_key, 0) + 1
return degrees
def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]:
"""Return only real Hole Wizard placement points from a position sketch.
SolidWorks Hole Wizard position sketches often include construction
segments whose endpoints are reference geometry, not hole centers. Older
parser JSON exposes those endpoints as ordinary sketch points, so we filter
them generically here instead of letting every point become a hole.
"""
entities = sketch.get("entities") or []
point_entities: list[tuple[list[float], Optional[bool], bool]] = []
construction_endpoints: set[tuple[float, float, float]] = set()
for entity in entities:
point = entity.get("point_mm")
if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2:
candidate_flag, construction_reference = _hole_position_entity_flags(entity)
point_entities.append(
(
[float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)],
candidate_flag,
construction_reference,
)
)
continue
if not entity.get("construction"):
continue
entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower()
if "line" not in entity_type:
continue
for key in ("start_mm", "end_mm"):
endpoint = entity.get(key)
if isinstance(endpoint, list) and len(endpoint) >= 2:
construction_endpoints.add(_rounded_point_key(endpoint))
if not point_entities:
return []
explicit_candidates = [
point for point, candidate_flag, _ in point_entities if candidate_flag is True
]
if explicit_candidates:
return _dedupe_points(explicit_candidates)
endpoint_degrees = _construction_endpoint_degrees(sketch)
if endpoint_degrees:
filtered = []
for point, candidate_flag, construction_reference in point_entities:
point_key = _rounded_point_key(point)
degree = endpoint_degrees.get(point_key, 0)
if candidate_flag is False and construction_reference and degree <= 1:
continue
if degree >= 2 or not construction_reference:
filtered.append(point)
filtered = _dedupe_points(filtered)
non_origin_filtered = [point for point in filtered if not _is_near_origin(point)]
if non_origin_filtered:
return _dedupe_points(non_origin_filtered)
if filtered:
return filtered
raw_points = _dedupe_points([point for point, _, _ in point_entities])
if not raw_points or not construction_endpoints:
return raw_points
legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints]
non_origin_raw = [point for point in raw_points if not _is_near_origin(point)]
non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)]
if non_origin_filtered:
return _dedupe_points(non_origin_filtered)
if non_origin_raw:
return _dedupe_points(non_origin_raw)
return _dedupe_points(legacy_filtered or raw_points)
def _dedupe_points(points: list[list[float]]) -> list[list[float]]:
result = []
seen = set()
for point in points:
key = _rounded_point_key(point)
if key in seen:
continue
seen.add(key)
result.append(point)
return result
def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool:
return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance
def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]:
z = point[2] if len(point) > 2 else 0
return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits))
def _convert_sw_linear_pattern(
feature: Dict[str, Any],
index: int,
previous_build_op: Optional[Dict[str, Any]],
source_frame: Optional[Dict[str, Any]] = None,
sketches: Optional[list[Dict[str, Any]]] = None,
source_bbox: Optional[list[float]] = None,
) -> Dict[str, Any]:
data_block = feature.get("linear_pattern_data", {})
source_features = data_block.get("source_features") or []
if not source_features and previous_build_op:
source_features = [previous_build_op.get("source_feature", {})]
spacing_1 = data_block.get("spacing_1")
spacing_2 = data_block.get("spacing_2")
direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame)
direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame)
direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame)
direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame)
if data_block.get("direction_1_reverse") is True:
direction_1 = _reverse_pattern_direction(direction_1)
if data_block.get("direction_2_reverse") is True:
direction_2 = _reverse_pattern_direction(direction_2)
source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or [])
if data_block.get("direction_1") is None:
direction_1 = _choose_pattern_direction_sign(
direction_1,
spacing_1 or 0,
int(data_block.get("pattern_count_1") or 1),
source_op_bbox,
source_bbox,
)
if data_block.get("direction_2") is None:
direction_2 = _choose_pattern_direction_sign(
direction_2,
spacing_2 or 0,
int(data_block.get("pattern_count_2") or 1),
source_op_bbox,
source_bbox,
)
explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature)
return {
"id": feature.get("id"),
"name": feature.get("name"),
"type": "linear_pattern",
"parameters": {
"source_features": source_features,
"total_instances": data_block.get("pattern_count_1") or 1,
"spacing_mm": spacing_1 or 0,
"direction1": direction_1,
"direction2": direction_2,
},
"raw_parameters": {
"d1_total_instances": data_block.get("pattern_count_1") or 1,
"d2_total_instances": data_block.get("pattern_count_2") or 1,
"d1_spacing_mm": spacing_1 or 0,
"d2_spacing_mm": spacing_2 or 0,
"direction1": direction_1,
"direction2": direction_2,
"explicit_offsets_mm": explicit_offsets,
},
"source_feature": _source_feature(feature, index),
"source_owned_faces": _source_owned_faces(feature),
}
def _owned_face_pattern_offsets(
source_op: Optional[Dict[str, Any]],
pattern_feature: Dict[str, Any],
) -> list[list[float]]:
if not source_op:
return []
source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or [])
pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature))
if not source_faces or not pattern_faces:
return []
votes: Dict[tuple[float, float, float], int] = {}
for pattern_face in pattern_faces:
for source_face in source_faces:
if pattern_face["kind"] != source_face["kind"]:
continue
if not _similar_bbox_size(pattern_face["size"], source_face["size"]):
continue
offset = tuple(
round(pattern_face["center"][axis] - source_face["center"][axis], 3)
for axis in range(3)
)
if math.sqrt(sum(component * component for component in offset)) < 1e-6:
continue
votes[offset] = votes.get(offset, 0) + 1
if not votes:
return []
threshold = max(1, min(2, len(source_faces)))
offsets = [offset for offset, count in votes.items() if count >= threshold]
offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset))
return [[float(value) for value in offset] for offset in offsets]
def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
signatures = []
for face in faces:
if not isinstance(face, dict):
continue
box = face.get("box_m")
if not isinstance(box, list) or len(box) < 6:
continue
box_mm = [float(value) * 1000 for value in box[:6]]
surface = face.get("surface") if isinstance(face.get("surface"), dict) else {}
kind = "other"
if surface.get("is_cylinder"):
kind = "cylinder"
elif surface.get("is_cone"):
kind = "cone"
elif surface.get("is_plane"):
kind = "plane"
signatures.append(
{
"kind": kind,
"center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)],
"size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)],
}
)
return signatures
def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool:
return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3))
def _source_pattern_frame(
previous_build_op: Optional[Dict[str, Any]],
sketches: list[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
if not previous_build_op:
return None
params = previous_build_op.get("parameters") or {}
host_frame = ((params.get("host_face") or {}).get("frame") or {})
if host_frame.get("x_dir") and host_frame.get("y_dir"):
return host_frame
sketch_id = previous_build_op.get("sketch")
for sketch in sketches:
if sketch.get("id") == sketch_id:
workplane = sketch.get("workplane") or {}
if workplane.get("x_dir") and workplane.get("y_dir"):
return workplane
return None
def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]:
bbox = (data.get("validation_hints") or {}).get("part_box_m")
if isinstance(bbox, list) and len(bbox) >= 6:
return [float(v) * 1000 for v in bbox[:6]]
return None
def _operation_profile_bbox(
op: Optional[Dict[str, Any]],
sketches: list[Dict[str, Any]],
) -> Optional[list[float]]:
if not op:
return None
if op.get("type") == "hole":
host_face = (op.get("parameters") or {}).get("host_face") or {}
positions = [
_hole_position_to_model(pos.get("mm"), host_face)
for pos in (op.get("parameters") or {}).get("positions", [])
if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3
]
if positions:
return _points_bbox(positions)
sketch_id = op.get("sketch")
sketch = next((item for item in sketches if item.get("id") == sketch_id), None)
if not sketch:
return None
points = []
workplane = sketch.get("workplane") or {}
origin = workplane.get("origin_mm") or [0, 0, 0]
x_dir = workplane.get("x_dir") or [1, 0, 0]
y_dir = workplane.get("y_dir") or [0, 1, 0]
for entity in sketch.get("entities", []) or []:
if entity.get("type") == "circle":
center = entity.get("center") or [0, 0]
radius = float(entity.get("radius_mm") or 0)
for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)):
points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy]))
for key in ("start", "end", "center", "point"):
point = entity.get(key)
if isinstance(point, list) and len(point) >= 2:
points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point))
return _points_bbox(points)
def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]:
return [
float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1])
for i in range(3)
]
def _points_bbox(points: list[list[float]]) -> Optional[list[float]]:
if not points:
return None
return [
min(point[0] for point in points),
min(point[1] for point in points),
min(point[2] for point in points),
max(point[0] for point in points),
max(point[1] for point in points),
max(point[2] for point in points),
]
def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]:
frame = host_face.get("frame") if isinstance(host_face, dict) else {}
if not isinstance(frame, dict):
return [float(v) for v in (point + [0, 0, 0])[:3]]
origin = frame.get("origin_mm") or [0, 0, 0]
x_dir = frame.get("x_dir") or [1, 0, 0]
y_dir = frame.get("y_dir") or [0, 1, 0]
values = [float(v) for v in (point + [0, 0, 0])[:3]]
return [
float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1]
for i in range(3)
]
def _choose_pattern_direction_sign(
direction: Dict[str, Any],
spacing: float,
count: int,
source_op_bbox: Optional[list[float]],
source_bbox: Optional[list[float]],
) -> Dict[str, Any]:
vector = direction.get("vector")
if (
not isinstance(vector, list)
or len(vector) < 3
or not spacing
or count <= 1
or not source_op_bbox
or not source_bbox
):
return direction
unit = _unit3(vector)
distance = float(spacing) * (count - 1)
positive = [component * distance for component in unit]
negative = [-component * distance for component in unit]
positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox)
negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox)
if abs(positive_score - negative_score) <= 1e-9:
positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox)
negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox)
copied = dict(direction)
if negative_score + 1e-9 < positive_score:
copied["vector"] = [-component for component in unit]
copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox"
return copied
copied["vector"] = unit
if positive_score + 1e-9 < negative_score:
copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox"
return copied
def _unit3(vector: list[Any]) -> list[float]:
raw = [float(vector[i]) for i in range(3)]
length = math.sqrt(sum(v * v for v in raw))
if length <= 0:
return [0.0, 0.0, 0.0]
return [v / length for v in raw]
def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]:
return [
bbox[0] + offset[0],
bbox[1] + offset[1],
bbox[2] + offset[2],
bbox[3] + offset[0],
bbox[4] + offset[1],
bbox[5] + offset[2],
]
def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float:
score = 0.0
for axis in range(3):
score += max(source[axis] - candidate[axis], 0)
score += max(candidate[axis + 3] - source[axis + 3], 0)
return score
def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float:
score = 0.0
for axis in range(3):
source_center = (source[axis] + source[axis + 3]) / 2
candidate_center = (candidate[axis] + candidate[axis + 3]) / 2
axis_size = max(source[axis + 3] - source[axis], 1.0)
score += abs(candidate_center - source_center) / axis_size
return score
def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool:
text = f"{type_name} {feature.get('name', '')}".lower()
return "cut" in text or "切除" in text or "revcut" in text
def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float:
for key in ("depth", "blind_depth"):
value = data_block.get(key)
if value:
return abs(float(value))
owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block)
effective_depth = abs(float(data_block.get("effective_depth") or 0))
if (
owned_face_depth
and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or ""))
and data_block.get("effective_depth_source") == "feature_dimension"
and not data_block.get("depth")
and not data_block.get("blind_depth")
and not data_block.get("reverse_depth")
and effective_depth > owned_face_depth * 2
):
return owned_face_depth
owner_name = feature.get("name")
for dim in data_block.get("dimensions", []) or []:
name = dim.get("name") or ""
if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0):
return abs(float(dim.get("value")))
for dim in data_block.get("dimensions", []) or []:
if dim.get("owner") == owner_name and dim.get("value") not in (None, 0):
return abs(float(dim.get("value")))
if data_block.get("reverse_depth") not in (None, 0):
return abs(float(data_block.get("reverse_depth")))
if data_block.get("effective_depth") not in (None, 0):
return abs(float(data_block.get("effective_depth")))
return 0.0
def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]:
sketches = data_block.get("source_sketches") or []
workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None
if not isinstance(workplane, dict):
return None
normal = workplane.get("normal") or [0, 0, 1]
if not isinstance(normal, list) or len(normal) < 3:
return None
axis = max(range(3), key=lambda idx: abs(float(normal[idx])))
values: list[float] = []
for face in feature.get("owned_faces") or []:
if not isinstance(face, dict):
continue
box = face.get("box_m")
if isinstance(box, list) and len(box) >= 6:
values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000])
if not values:
return None
extent = max(values) - min(values)
return abs(extent) if extent > 1e-6 else None
def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]:
origin = workplane.get("origin_mm") or [0, 0, 0]
normal = workplane.get("normal") or [0, 0, 1]
x_dir = workplane.get("x_dir") or [1, 0, 0]
y_dir = workplane.get("y_dir") or [0, 1, 0]
return {
"surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]},
"frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]},
}
def _pattern_direction_from_plugin(
direction: Any,
axis: str,
source_frame: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if isinstance(direction, dict):
return direction
if source_frame:
key = "y_dir" if axis == "y" else "x_dir"
vector = source_frame.get(key)
if isinstance(vector, list) and len(vector) >= 3:
return {"vector": vector[:3], "source": f"source_feature_frame_{key}"}
if axis == "y":
return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"}
return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"}
def _pattern_direction_from_reference(
fallback: Dict[str, Any],
reference: Any,
source_frame: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
axis = _extract_axis_reference(reference)
if not axis:
return fallback
vector = axis.get("direction")
if not isinstance(vector, list) or len(vector) < 3:
return fallback
model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3]
model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm")
return {
"vector": _unit3(model_vector),
"origin_mm": model_origin,
"source": axis.get("source") or "direction_reference",
}
def _sketch_vector_to_model(
vector: list[Any],
source_frame: Optional[Dict[str, Any]],
) -> Optional[list[float]]:
if not source_frame:
return None
x_dir = source_frame.get("x_dir")
y_dir = source_frame.get("y_dir")
normal = source_frame.get("normal")
if not (
isinstance(x_dir, list)
and len(x_dir) >= 3
and isinstance(y_dir, list)
and len(y_dir) >= 3
):
return None
if not (isinstance(normal, list) and len(normal) >= 3):
normal = [
float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]),
float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]),
float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]),
]
values = [float(v) for v in (vector + [0, 0, 0])[:3]]
return [
values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i])
for i in range(3)
]
def _sketch_point_to_model(
point: Any,
source_frame: Optional[Dict[str, Any]],
) -> Optional[list[float]]:
if not isinstance(point, list) or len(point) < 3 or not source_frame:
return None
origin = source_frame.get("origin_mm")
vector = _sketch_vector_to_model(point[:3], source_frame)
if not (isinstance(origin, list) and len(origin) >= 3 and vector):
return None
return [float(origin[i]) + vector[i] for i in range(3)]
def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]:
vector = direction.get("vector")
if not isinstance(vector, list) or len(vector) < 3:
return direction
copied = dict(direction)
copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])]
copied["source"] = f"{direction.get('source', 'direction')}_reversed"
return copied
def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]:
if not isinstance(reference, dict):
return None
if reference.get("kind") == "null":
return None
obj = reference.get("object")
if isinstance(obj, dict) and obj.get("kind") == "null":
return None
return reference
def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]:
if not isinstance(reference, dict):
return None
if reference.get("origin_mm") and reference.get("direction"):
return {
"origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]],
"direction": [float(v) for v in reference.get("direction", [])[:3]],
"source": reference.get("source") or "axis_reference",
}
obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference
if obj.get("kind") == "null":
return None
line_params = obj.get("line_params")
if isinstance(line_params, list) and len(line_params) >= 6:
return {
"origin_mm": [float(v) * 1000 for v in line_params[:3]],
"direction": [float(v) for v in line_params[3:6]],
"source": reference.get("source") or "selection_line_params",
}
curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {}
curve_line_params = curve.get("line_params")
if isinstance(curve_line_params, list) and len(curve_line_params) >= 6:
return {
"origin_mm": [float(v) * 1000 for v in curve_line_params[:3]],
"direction": [float(v) for v in curve_line_params[3:6]],
"source": reference.get("source") or "selection_curve_line_params",
}
return None
def _selection_objects(selections: Any) -> list[Dict[str, Any]]:
objects: list[Dict[str, Any]] = []
if not isinstance(selections, list):
return objects
for selection in selections:
if not isinstance(selection, dict):
continue
obj = selection.get("object")
if isinstance(obj, dict) and obj.get("kind") != "null":
objects.append(obj)
return objects
def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]:
for obj in _selection_objects(selections):
axis = _extract_axis_reference(obj)
if axis:
axis["source"] = "feature_selection_axis"
return axis
return None
def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]:
for obj in _selection_objects(selections):
if obj.get("kind") != "face":
continue
surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {}
frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {}
if not frame:
continue
normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3]
origin = frame.get("origin_mm")
if not origin:
plane_params = surface.get("plane_params")
if isinstance(plane_params, list) and len(plane_params) >= 6:
origin = [float(v) * 1000 for v in plane_params[3:6]]
if not origin:
origin = [0, 0, 0]
x_dir = frame.get("x_dir") or [1, 0, 0]
y_dir = frame.get("y_dir") or [0, 1, 0]
origin_values = list(origin)
x_values = list(x_dir)
y_values = list(y_dir)
normal_values = list(normal)
return {
"surface": surface,
"frame": {
"origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]],
"x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]],
"y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]],
"normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]],
},
"source": "feature_selection_face",
}
return None
def _tuple3(values: Any) -> tuple[float, float, float]:
values = list(values or [0, 0, 0])
values = (values + [0, 0, 0])[:3]
return tuple(values)
def _point_m_to_mm(point: Any) -> tuple[float, float, float]:
values = list(point or [0, 0, 0])
values = (values + [0, 0, 0])[:3]
return tuple(float(value) * 1000 for value in values)
def _scale_point(point: Any) -> list[float]:
values = [0 if value is None else float(value) for value in (point or [0, 0])]
return [_scale_length(value) for value in values[:2]]
def _scale_length(value: Any) -> float:
value = 0 if value is None else float(value)
return value * 1000 if abs(value) <= 10 else value
def _to_degrees(value: Any) -> float:
value = 0 if value is None else float(value)
return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value