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

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

1960 lines
85 KiB
Python

"""SolidWorks plugin JSON to backend-IR conversion."""
from __future__ import annotations
import json
import math
import os
from copy import deepcopy
from typing import Any, Dict, Optional
from .common import (
SW_END_CONDITIONS,
THROUGH_CUT_AMOUNT_MM,
_point_m_to_mm,
_scale_point,
_scale_length,
_to_degrees,
_unit3,
_points_bbox,
_rounded_point_key,
_dedupe_points,
_is_near_origin,
_similar_bbox_size,
_translated_bbox,
_bbox_overflow_score,
_bbox_center_distance_score,
_bbox_area_2d,
_loop_bbox,
)
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 _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 _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 _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 _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 _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