5185 lines
231 KiB
Python
5185 lines
231 KiB
Python
"""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
|