feat: 推进一级关系参数化编辑与参数导出

This commit is contained in:
2026-08-13 17:50:20 +08:00
parent 19364d81b5
commit 7d814d2939
21 changed files with 2075 additions and 231 deletions
+2 -2
View File
@@ -1243,7 +1243,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.apply_property_button.setObjectName("parametricModelButton")
self.apply_property_button.setMinimumHeight(34)
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的一个参数;一次只执行一个几何修改,成功后可撤销")
help_tip(self.apply_property_button, "应用当前被修改的参数;多个目标值会按表格顺序依次执行,失败时停止后续修改")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
@@ -1255,7 +1255,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.export_parameters_button.setObjectName("exportParametersButton")
self.export_parameters_button.setMinimumHeight(34)
self.export_parameters_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.export_parameters_button, "把已勾选为输入参数的尺寸导出为 data.json。")
help_tip(self.export_parameters_button, "把已勾选的尺寸输入导出为 data.json,并生成可外部调用的参数化组件 main.py")
self.export_parameters_button.clicked.connect(self.export_selected_parameters)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
+21 -2
View File
@@ -1138,6 +1138,12 @@ class FeatureMixin:
"slot_blockers": blocker,
}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
feature_angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(info.get("angular_span"))
)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -1146,6 +1152,10 @@ class FeatureMixin:
scoped_info = dict(info)
scoped_info["height_estimate"] = axis_range["span"]
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
if feature_angular_span is not None:
scoped_info["angular_span"] = feature_angular_span
scoped_info["same_domain_angular_span"] = feature_angular_span
scoped_info["is_full_cylinder"] = feature_angular_span >= math.tau * 0.92
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
@@ -1190,7 +1200,6 @@ class FeatureMixin:
"feature_bottom_note": feature.get("feature_bottom_note"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": scoped_info.get("height_estimate"),
"same_domain_face_ids": axis_range["same_domain_face_ids"],
"same_domain_face_count": axis_range["same_domain_face_count"],
@@ -1204,6 +1213,9 @@ class FeatureMixin:
**topology_fields,
**cutter_plan,
**fill_plan,
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
}
def cylindrical_axis_move_plan(
@@ -2639,7 +2651,9 @@ class FeatureMixin:
"radius": info.get("radius"),
"axis": info.get("axis"),
"axis_point": info.get("axis_point"),
"angular_span": info.get("angular_span"),
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
"height_estimate": scoped_info.get("height_estimate"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
@@ -3331,6 +3345,7 @@ class FeatureMixin:
candidates: list[tuple[float, tuple[float, float, float], dict[str, object]]] = []
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
plane_axis = surf.Plane().Axis().Direction()
for adjacent_id in adjacent_face_ids:
if adjacent_id < 0 or adjacent_id >= len(self.faces):
continue
@@ -3346,6 +3361,9 @@ class FeatureMixin:
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92:
continue
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
except Exception:
@@ -3377,6 +3395,7 @@ class FeatureMixin:
{
"cap_axis_face_id": adjacent_id,
"cap_axis_end": end_label,
"cap_plane_axis_alignment": plane_axis_alignment,
"cap_axis_parameter": cap_parameter,
"cap_axis_start_parameter": v_min,
"cap_axis_end_parameter": v_max,
+6
View File
@@ -28,6 +28,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.push_pull_face_keep_relations(int(args[0]), float(args[1]))
if operation == "move_face_plane_offset_local":
return model.move_face_plane_offset_local(int(args[0]), float(args[1]))
if operation == "translate_face_plane_offset_owning":
return model.translate_face_plane_offset_owning(int(args[0]), float(args[1]))
if operation == "resize_face_area_local":
return model.resize_face_area_local(int(args[0]), float(args[1]))
if operation == "resize_face_area":
@@ -62,6 +64,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_cylindrical_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss_height":
return model.resize_cylindrical_boss_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss":
return model.resize_cylindrical_boss(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_height_owning_scale":
return model.resize_cylindrical_height_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_cone_reference_radius":
@@ -78,6 +82,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_cylindrical_owning_scale(int(args[0]), float(args[1]))
if operation == "move_cylindrical_hole_axis":
return model.move_cylindrical_hole_axis(int(args[0]), _point3(args[1], operation))
if operation == "move_cylindrical_boss_axis":
return model.move_cylindrical_boss_axis(int(args[0]), _point3(args[1], operation))
if operation == "suppress_cylindrical_hole":
return model.suppress_cylindrical_hole(int(args[0]))
if operation == "resize_cylindrical_depth":
+187 -11
View File
@@ -127,6 +127,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._edge_first_level_fact_cache: dict[int, dict[str, object]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._part_solid_count_cache: dict[int, int] = {}
self._open_shell_context_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -233,6 +234,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_first_level_topology_cache.clear()
self._edge_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
self._part_solid_count_cache.clear()
self._open_shell_context_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
@@ -247,6 +249,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
self._part_solid_count_cache[part.id] = len(part_solids)
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
if part_solids:
@@ -406,15 +409,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
info["feature_edit_actions"] = "拉伸/切除平面"
if bool(info.get("has_inner_boundaries")):
info["local_face_deform_ready"] = False
info["local_face_deform_face_count"] = 1
info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用拉伸/切除、孔或槽的专门修改入口。"
else:
info["local_face_deform_ready"] = True
info["local_face_deform_face_count"] = 1
info["local_face_deform_blocker"] = ""
info["local_face_deform_status"] = "deferred"
info.update(
self._local_face_plane_size_info(
face_id,
@@ -423,6 +417,15 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
center=_tuple_or_none(info.get("area_center")),
)
)
if bool(info.get("has_inner_boundaries")):
info["local_face_deform_ready"] = False
info["local_face_deform_face_count"] = 1
info["local_face_deform_blocker"] = "当前 Face 有内孔/内边界;请优先使用拉伸/切除、孔或槽的专门修改入口。"
else:
info.update(self._local_face_deform_readiness(face_id))
if bool(info.get("local_face_deform_ready")):
info["local_face_deform_status"] = "ready"
info.update(self._local_face_size_parameter_readiness(face_id, info))
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
@@ -604,6 +607,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
result: dict[str, object] = {
"same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids),
"feature_highlight_face_ids": tuple(side_face_ids),
"angular_span": combined_span,
"same_domain_angular_span": combined_span,
"same_domain_note": same_domain_note,
@@ -748,6 +752,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
center=_tuple_or_none(info.get("area_center")),
)
)
info.update(self._local_face_size_parameter_readiness(face_id, info))
try:
info.update(self.face_first_level_topology(face_id))
info.update(self.face_first_level_facts(face_id, scope="face"))
@@ -2069,19 +2074,51 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"local_face_width_direction": direction_groups[0]["direction"],
"local_face_height_direction": direction_groups[1]["direction"],
}
result.update(self._local_face_size_parameter_readiness(face_id, {**info, **result}))
prismatic_rebuild_blockers: list[str] = []
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
reference_face_id = opposite_face_id
part_id = self.face_part_ids[face_id] if 0 <= face_id < len(self.face_part_ids) else -1
part_solid_count = self._part_solid_count_cache.get(part_id, 0)
if feature_semantics not in {"additive-boss", "subtractive-pocket"}:
prismatic_rebuild_blockers.append("当前矩形平面还没有稳定判断为凸台或口袋。")
if len(connected_side_ids) != 4:
prismatic_rebuild_blockers.append("当前矩形特征需要四个侧壁都能识别后才开放长宽局部重建。")
if reference_face_id is None:
prismatic_rebuild_blockers.append("当前矩形特征缺少稳定参考面。")
if extrusion is None or extrusion <= 1e-9:
prismatic_rebuild_blockers.append("当前矩形特征缺少稳定高度/深度。")
if part_solid_count != 1:
prismatic_rebuild_blockers.append("矩形凸台/口袋长宽局部重建当前只对单 Solid 零件开放。")
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": opposite_face_id,
"prismatic_reference_face_id": reference_face_id,
"prismatic_extrusion_confidence": extrusion_confidence,
"confidence": extrusion_confidence,
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
"part_solid_count": part_solid_count,
}
)
else:
prismatic_rebuild_blockers.append("当前矩形平面还没有识别出稳定高度/深度,不能按完整凸台/口袋局部重建。")
if prismatic_rebuild_blockers:
result.update(
{
"rectangular_prismatic_size_rebuild_ready": False,
"rectangular_prismatic_size_rebuild_blocker": "".join(prismatic_rebuild_blockers),
}
)
else:
result.update(
{
"rectangular_prismatic_size_rebuild_ready": True,
"rectangular_prismatic_size_rebuild_blocker": "",
}
)
return result
@@ -2588,6 +2625,22 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
**fillet_info,
}
)
if angular_span >= math.tau * 0.92:
result.update(
{
"slot_kind": "",
"slot_status": "",
"slot_risk": "",
"slot_blockers": "",
"slot_angular_span": "",
"slot_open_angle": "",
"slot_chord_width_estimate": "",
"slot_arc_length_estimate": "",
"slot_sagitta_depth_estimate": "",
"feature_slot_face_ids": (),
"feature_slot_boundary_face_ids": (),
}
)
scoped_readiness_info = dict(result)
scoped_readiness_info["angular_span"] = combined_angular_span
scoped_readiness_info["height_estimate"] = axis_range["span"]
@@ -3685,7 +3738,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
adjacent_face_ids=adjacent_face_ids,
relation_scope="cylindrical-feature",
)
angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
angular_span = (
_float_or_none(feature.get("slot_angular_span"))
or _float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(info.get("angular_span"))
)
topology = {
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_depth": 1,
@@ -3913,6 +3972,123 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"has_inner_boundaries": inner_boundary_wires > 0,
}
def _local_face_size_parameter_readiness(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
blockers: list[str] = []
if face_id < 0 or face_id >= len(self.faces):
blockers.append("Face ID 不存在。")
if str(info.get("surface") or "") != "plane":
blockers.append("面内长度/宽度只对平面矩形 Face 开放。")
if bool(info.get("has_inner_boundaries")):
blockers.append("当前 Face 有内孔/内边界,请优先使用孔或槽的专门修改入口。")
try:
boundary_wires = int(info.get("boundary_wires", 0) or 0)
except (TypeError, ValueError):
boundary_wires = 0
if boundary_wires and boundary_wires != 1:
blockers.append("当前 Face 不是单一外轮廓。")
width = _float_or_none(info.get("local_face_width"))
height = _float_or_none(info.get("local_face_height"))
width_direction = _tuple_or_none(info.get("local_face_width_direction"))
height_direction = _tuple_or_none(info.get("local_face_height_direction"))
center = _tuple_or_none(info.get("local_face_size_center")) or _tuple_or_none(info.get("area_center"))
if width is None or width <= 1e-9:
blockers.append("当前 Face 缺少稳定的面内长度。")
if height is None or height <= 1e-9:
blockers.append("当前 Face 缺少稳定的面内宽度。")
if width_direction is None or height_direction is None or center is None:
blockers.append("当前 Face 缺少稳定的面内方向或中心。")
edge_ids: list[int] = []
if 0 <= face_id < len(self.faces):
try:
edge_ids = self._face_boundary_edge_ids(face_id)
except Exception:
edge_ids = []
try:
boundary_edges = int(info.get("boundary_edges", len(edge_ids)) or len(edge_ids))
except (TypeError, ValueError):
boundary_edges = len(edge_ids)
rectangular_groups: list[dict[str, object]] = []
if boundary_edges != 4 or len(edge_ids) != 4:
blockers.append("面内长度/宽度当前只对四边矩形平面开放。")
else:
direction_groups: list[dict[str, object]] = []
for edge_id in edge_ids:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() != GeomAbs_Line:
blockers.append("当前 Face 的边界包含非直线边。")
break
start = _point_tuple(curve.Value(curve.FirstParameter()))
end = _point_tuple(curve.Value(curve.LastParameter()))
vector = _tuple_sub(end, start)
length = math.sqrt(_tuple_dot(vector, vector))
direction = _tuple_normalized(vector)
except Exception:
blockers.append("当前 Face 的边界直线不能稳定读取。")
break
if direction is None or length <= 1e-9:
blockers.append("当前 Face 的边界存在退化直线边。")
break
matched_group = None
for group in direction_groups:
group_direction = _tuple_or_none(group.get("direction"))
if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999:
matched_group = group
break
if matched_group is None:
matched_group = {"direction": direction, "lengths": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
blockers.append("当前 Face 的四条边没有形成两组稳定平行对边。")
else:
first_direction = _tuple_or_none(direction_groups[0].get("direction"))
second_direction = _tuple_or_none(direction_groups[1].get("direction"))
if (
first_direction is None
or second_direction is None
or abs(_tuple_dot(first_direction, second_direction)) > 0.01
):
blockers.append("当前 Face 的两组对边不是稳定垂直关系。")
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / max(len(lengths), 1)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
blockers.append("当前 Face 的相对边长度不一致。")
break
rectangular_groups = direction_groups
if blockers:
return {
"local_face_size_edit_ready": False,
"local_face_size_edit_blocker": "".join(dict.fromkeys(blockers)),
}
result: dict[str, object] = {
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
}
if rectangular_groups:
rectangular_groups.sort(
key=lambda group: sum(float(item) for item in group["lengths"]) / max(len(group["lengths"]), 1),
reverse=True,
)
length_group = rectangular_groups[0]
width_group = rectangular_groups[1]
length_values = [float(item) for item in length_group["lengths"]]
width_values = [float(item) for item in width_group["lengths"]]
result.update(
{
"local_face_width": sum(length_values) / len(length_values),
"local_face_height": sum(width_values) / len(width_values),
"local_face_width_direction": length_group["direction"],
"local_face_height_direction": width_group["direction"],
"local_face_size_center": center,
}
)
return result
def _local_face_deform_readiness(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {
+60 -20
View File
@@ -3269,9 +3269,13 @@ class OperationMixin:
outward_direction = _tuple_normalized(_tuple_or_none(info.get("push_pull_outward_direction")))
current_plane_position = None
target_plane_position = None
current_plane_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
target_plane_center = None
if plane_origin is not None and outward_direction is not None:
current_plane_position = _tuple_dot(plane_origin, outward_direction)
target_plane_position = current_plane_position + float(distance)
if current_plane_center is not None:
target_plane_center = _tuple_add(current_plane_center, _tuple_scale(outward_direction, float(distance)))
return {
"status": status,
@@ -3294,6 +3298,8 @@ class OperationMixin:
"plane_direction": outward_direction,
"current_plane_position": current_plane_position,
"target_plane_position": target_plane_position,
"current_plane_center": current_plane_center,
"target_plane_center": target_plane_center,
"push_pull_inward_material_depth": inward_material_depth,
"push_pull_inward_cut_ratio": inward_cut_ratio,
"cylindrical_cap_extension_old_height": (
@@ -8746,6 +8752,7 @@ class OperationMixin:
metric = ""
target: float | tuple[float, ...] | None = None
tolerance = 1e-4
center_tolerance: float | None = None
getter: Callable[[int], float | tuple[float, ...] | None] | None = None
target_area = _float_or_none(plan.get("target_area"))
@@ -8794,6 +8801,8 @@ class OperationMixin:
target = target_position
bbox_diagonal = _float_or_none(plan.get("bbox_diagonal")) or _shape_diagonal(self.shape)
tolerance = max(bbox_diagonal * 1e-4, abs(target_position) * 1e-5, 1e-4)
target_plane_center = _tuple_or_none(plan.get("target_plane_center"))
center_tolerance = max(bbox_diagonal * 0.15, tolerance * 20.0, 1e-3) if target_plane_center is not None else None
def plane_position_getter(face_id: int) -> float | None:
if face_id < 0 or face_id >= len(self.faces):
@@ -8890,6 +8899,46 @@ class OperationMixin:
plan_face_id = _int_or_none(plan.get("face_id"))
if plan_face_id is not None:
preferred_ids.append(plan_face_id)
def candidate_center_error(face_id: int) -> float | None:
if metric != "plane_position":
return None
target_center = _tuple_or_none(plan.get("target_plane_center"))
if target_center is None:
return None
try:
info = self.quick_face_info(face_id)
except Exception:
return None
actual_center = _tuple_or_none(info.get("area_center")) or _tuple_or_none(info.get("bbox_center"))
if actual_center is None:
return None
return _vector_length(_tuple_sub(actual_center, target_center))
def candidate_record(face_id: int, actual: object, error: float) -> dict[str, object]:
record: dict[str, object] = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
center_error = candidate_center_error(face_id)
if center_error is not None:
record["center_error"] = center_error
if center_tolerance is not None:
record["center_tolerance"] = center_tolerance
return record
def candidate_sort_key(record: dict[str, object]) -> tuple[int, float, float]:
center_error = _float_or_none(record.get("center_error"))
center_limit = _float_or_none(record.get("center_tolerance"))
center_penalty = 0
if center_error is not None and center_limit is not None and center_error > center_limit:
center_penalty = 1
return (center_penalty, float(record["error"]), center_error if center_error is not None else 0.0)
if preferred_ids:
preferred_best: dict[str, object] | None = None
for face_id in preferred_ids:
@@ -8902,16 +8951,9 @@ class OperationMixin:
if actual is None:
continue
error = _result_value_error(actual, target)
if preferred_best is None or error < float(preferred_best["error"]):
preferred_best = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
record = candidate_record(face_id, actual, error)
if preferred_best is None or candidate_sort_key(record) < candidate_sort_key(preferred_best):
preferred_best = record
if preferred_best is not None and float(preferred_best["error"]) <= tolerance:
return preferred_best
@@ -8924,16 +8966,9 @@ class OperationMixin:
if actual is None:
continue
error = _result_value_error(actual, target)
if best is None or error < float(best["error"]):
best = {
"face_id": face_id,
"metric": metric,
"actual": actual,
"target": target,
"error": error,
"tolerance": tolerance,
"scope": scope,
}
record = candidate_record(face_id, actual, error)
if best is None or candidate_sort_key(record) < candidate_sort_key(best):
best = record
if best is not None:
return best
return {
@@ -9011,6 +9046,11 @@ class OperationMixin:
all_ids = filtered(range(len(self.faces)))
if not all_ids and solid_id is not None and solid_id >= 0:
all_ids = filtered(range(len(self.faces)), require_solid=False)
elif target_kind == "solid" and solid_id is not None and solid_id >= 0 and surface == "plane":
relaxed_ids = filtered(range(len(self.faces)), require_solid=False)
for face_id in relaxed_ids:
if face_id not in all_ids:
all_ids.append(face_id)
combined: list[int] = []
for face_id in [*primary_ids, *all_ids]:
if face_id not in combined:
+570
View File
@@ -0,0 +1,570 @@
from __future__ import annotations
import argparse
from datetime import datetime
import json
import re
from pathlib import Path
import sys
import traceback
from .isolated_edit_worker import _execute
from .model import StepModel
COMPONENT_SCHEMA = "step-editor-parametric-component-v1"
_COMPONENT_SEQUENCE_RE = re.compile(r"^(?P<index>\d{3,})_(?P<name>.+)$")
_ACTION_OPERATION_MAP = {
"push_pull_face": "push_pull_face",
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"move_selected_face_plane_position_by_translation": "translate_face_plane_offset_owning",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
"resize_shell_thickness": "resize_shell_thickness",
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
"resize_hole": "resize_cylindrical_hole",
"resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale",
"resize_hole_depth": "resize_cylindrical_depth",
"resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale",
"resize_slot_width": "resize_cylindrical_slot_width",
"resize_slot_depth": "resize_cylindrical_slot_depth",
"resize_slot_arc_length": "resize_cylindrical_slot_arc_length",
"resize_slot_angular_span": "resize_cylindrical_slot_angular_span",
"resize_slot_total_length": "resize_cylindrical_slot_total_length",
"resize_slot_center_distance": "resize_cylindrical_slot_center_distance",
"move_cylindrical_hole_axis": "move_cylindrical_hole_axis",
"move_cylindrical_slot_axis": "move_cylindrical_slot_axis",
"suppress_hole": "suppress_cylindrical_hole",
"resize_boss": "resize_cylindrical_boss",
"resize_boss_height": "resize_cylindrical_boss_height",
"resize_cylinder_height": "resize_cylindrical_height",
"resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale",
"move_cylindrical_boss_axis": "move_cylindrical_boss_axis",
"resize_cone_reference_radius": "resize_cone_reference_radius",
"resize_cone_semi_angle": "resize_cone_semi_angle",
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_any_edge_length": "resize_general_edge_length",
"move_edge_start_point": "move_edge_endpoint",
"move_edge_end_point": "move_edge_endpoint",
"move_edge_center_point": "move_edge_center",
"move_circular_edge_axis_center": "move_circular_edge_axis_center",
"resize_ellipse_edge_major_radius": "resize_ellipse_edge_axis_radius",
"resize_ellipse_edge_minor_radius": "resize_ellipse_edge_axis_radius",
"resize_existing_fillet": "resize_existing_fillet",
"resize_existing_chamfer": "resize_existing_chamfer",
"fillet_edge": "fillet_edge",
"chamfer_edge": "chamfer_edge",
"chamfer_edge_asymmetric": "chamfer_edge_asymmetric",
"chamfer_edge_distance_angle": "chamfer_edge_distance_angle",
}
def sanitize_component_name(value: object, fallback: str = "STEP_Parametric") -> str:
text = str(value or "").strip() or fallback
text = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', "_", text)
text = re.sub(r"\s+", "_", text).strip(" ._")
return text or fallback
def default_component_root(project_root: Path | None = None) -> Path:
root = project_root or Path(__file__).resolve().parent.parent
return root / "nodes"
def next_component_dir(root: Path, component_name: object) -> Path:
base_name = sanitize_component_name(component_name)
max_index = -1
if root.is_dir():
for child in root.iterdir():
if not child.is_dir():
continue
match = _COMPONENT_SEQUENCE_RE.match(child.name)
if match:
max_index = max(max_index, int(match.group("index")))
return root / f"{max_index + 1:03d}_{base_name}"
def component_name_from_step(step_path: object) -> str:
try:
stem = Path(str(step_path)).stem
except Exception:
stem = ""
return sanitize_component_name(f"{stem}_STEP参数化组件" if stem else "STEP参数化组件")
def numeric_text(value: object) -> str:
text = str(value if value is not None else "").strip()
return text
def json_script_literal(value: object) -> str:
return json.dumps(value, ensure_ascii=False, indent=4)
def _float_or_text(value: object) -> object:
if isinstance(value, (int, float)):
return value
text = str(value if value is not None else "").strip()
try:
return float(text)
except ValueError:
return text
def _as_list3(value: object) -> list[float] | None:
if isinstance(value, str):
chunks = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()]
elif isinstance(value, (tuple, list)):
chunks = list(value)
else:
return None
if len(chunks) != 3:
return None
try:
return [float(chunks[0]), float(chunks[1]), float(chunks[2])]
except (TypeError, ValueError):
return None
def _target_object_id(spec: dict[str, object], selected_kind: str | None, selected_face_id: int | None, selected_edge_id: int | None) -> int | None:
if spec.get("source_face_id") not in {"", None}:
return int(spec["source_face_id"])
action = str(spec.get("action") or "")
kind = str(selected_kind or "")
if "edge" in action and selected_edge_id is not None and kind == "edge":
return int(selected_edge_id)
if selected_face_id is not None:
return int(selected_face_id)
if selected_edge_id is not None:
return int(selected_edge_id)
return None
def _target_kind_for_action(action: str, selected_kind: str | None) -> str:
if "edge" in action:
return "edge"
if selected_kind in {"face", "feature", "edge"}:
return str(selected_kind)
return "face"
def operation_for_action(action: object) -> str | None:
return _ACTION_OPERATION_MAP.get(str(action or ""))
def _axis_arg_for_face_size(action: str) -> str | None:
if "face_width" in action:
return "width"
if "face_height" in action:
return "height"
return None
def _mode_arg_for_torus(action: str) -> str | None:
if action == "resize_torus_major_radius":
return "major"
if action == "resize_torus_minor_radius":
return "minor"
return None
def _endpoint_arg_for_edge(action: str) -> str | None:
if action == "move_edge_start_point":
return "start"
if action == "move_edge_end_point":
return "end"
return None
def _ellipse_axis_arg(action: str) -> str | None:
if action == "resize_ellipse_edge_major_radius":
return "major"
if action == "resize_ellipse_edge_minor_radius":
return "minor"
return None
def component_edit_config_from_spec(
*,
parameter_row: dict[str, str],
spec: dict[str, object],
selected_kind: str | None,
selected_face_id: int | None,
selected_edge_id: int | None,
step_path: Path | None,
) -> dict[str, object] | None:
action = str(spec.get("action") or "")
operation = operation_for_action(action)
target_id = _target_object_id(spec, selected_kind, selected_face_id, selected_edge_id)
if not operation or target_id is None:
return None
value_type = str(spec.get("value_type", "number"))
default_value = parameter_row.get("default", "")
target_value: object
if value_type == "vector3":
target_value = _as_list3(default_value) or _as_list3(spec.get("current_raw")) or default_value
elif value_type in {"number", "positive", "integer", "integer_or_empty"}:
target_value = _float_or_text(default_value)
else:
target_value = default_value
args: list[object] = [int(target_id)]
target_arg: object = {"param": parameter_row["name"]}
transform = str(spec.get("target_transform") or "")
if transform:
target_arg = {
"param": parameter_row["name"],
"transform": transform,
"context": spec.get("transform_context", {}),
}
if action == "suppress_hole":
target_value = ""
else:
args.append(target_arg)
axis_arg = _axis_arg_for_face_size(action)
if axis_arg is not None:
args.append(axis_arg)
elif action in {"resize_torus_major_radius", "resize_torus_minor_radius"}:
args.append(_mode_arg_for_torus(action))
elif action in {"move_edge_start_point", "move_edge_end_point"}:
args.insert(1, _endpoint_arg_for_edge(action))
elif action in {"resize_ellipse_edge_major_radius", "resize_ellipse_edge_minor_radius"}:
args.append(_ellipse_axis_arg(action))
elif action in {
"resize_hole_depth",
"resize_hole_depth_owning_scale",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_total_length",
"resize_slot_center_distance",
}:
manual_id = spec.get("manual_bottom_face_id")
if manual_id in {"", None}:
manual_id = spec.get("slot_pair_manual_face_id")
args.append("" if manual_id in {"", None} else manual_id)
return {
"parameter": parameter_row["name"],
"displayName": parameter_row.get("displayName", parameter_row["name"]),
"targetKind": _target_kind_for_action(action, selected_kind),
"targetId": int(target_id),
"uiAction": action,
"operation": operation,
"args": args,
"default": target_value,
"valueType": value_type,
"scope": spec.get("scope_key", spec.get("scope_default", "")),
"scopeLabel": spec.get("scope_label", spec.get("scope_text", "")),
"sourceStep": str(step_path or ""),
"parameterKey": spec.get("key", ""),
}
def render_component_main_py(component: dict[str, object]) -> str:
project_root = str(Path(__file__).resolve().parent.parent)
component_name = sanitize_component_name(component.get("componentName") or component_name_from_step(component.get("sourceStep")))
output_parameter = {
"name": "output_step",
"displayName": "输出STEP",
"type": "file",
"ioRole": "output",
"default": "",
}
return f'''# -*- coding: utf-8 -*-
"""
STEP 参数化组件
这个文件按 FlowEditor 节点脚本方式生成
1. INPUT_PARAMETERS 是从软件导出参数勾选行直接嵌入的输入参数
2. PARAMETERS 会额外加上输出 STEP 文件参数供节点设计器生成输出端口
3. execute(inputs, params, context) FlowEditor 调用入口
4. main() 只用于本地命令行调试
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
PROJECT_ROOT = {json.dumps(project_root, ensure_ascii=False)}
if PROJECT_ROOT and PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from step_editor.parametric_component import run_embedded_component
INPUT_PARAMETERS = {json_script_literal(component.get("parameters", []))}
OUTPUT_PARAMETERS = [
{json_script_literal(output_parameter)}
]
PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS
COMPONENT = {json_script_literal(component)}
NODE_INFO = {{
"typeName": {json.dumps(component_name, ensure_ascii=False)},
"displayName": {json.dumps(component_name, ensure_ascii=False)},
"category": "几何参数化",
"icon": "icon.svg",
"parameters": PARAMETERS,
}}
def _value_from_inputs(name, inputs, params, default=""):
value = inputs.get(name) if isinstance(inputs, dict) else None
if value in (None, "") and isinstance(params, dict):
value = params.get(name)
if value in (None, ""):
value = default
return value
def _component_input_values(inputs, params):
values = {{}}
for item in INPUT_PARAMETERS:
name = item.get("name")
if not name:
continue
values[name] = _value_from_inputs(name, inputs, params, item.get("default", ""))
return values
def _default_output_step(work_dir, output_dir):
output_root = output_dir or os.path.join(work_dir, "output")
os.makedirs(output_root, exist_ok=True)
return os.path.join(output_root, COMPONENT.get("outputName") or "modified.step")
def run(inputs=None, output_step=None, work_dir=None):
return run_embedded_component(COMPONENT, inputs=inputs, output_step=output_step, work_dir=work_dir)
def execute(inputs, params, context):
"""
FlowEditor 调用入口
inputs上游节点传入值优先级高于 params
params节点属性面板参数
contextFlowEditor 上下文常见字段包括 work_dir / input_dir / output_dir
"""
inputs = inputs or {{}}
params = params or {{}}
context = context or {{}}
work_dir = context.get("work_dir") or os.getcwd()
output_dir = context.get("output_dir") or os.path.join(work_dir, "output")
output_step = _default_output_step(work_dir, output_dir)
result = run(
inputs=_component_input_values(inputs, params),
output_step=output_step,
work_dir=work_dir,
)
if not result.get("ok"):
raise RuntimeError(result.get("error") or json.dumps(result, ensure_ascii=False))
return {{
"output_step": result.get("outputStep", output_step),
"outputStep": result.get("outputStep", output_step),
"sourceStep": result.get("sourceStep", ""),
"messages": result.get("messages", []),
}}
def main(argv=None):
parser = argparse.ArgumentParser(description="Run generated STEP parametric component.")
parser.add_argument("--inputs", default="", help="JSON file or JSON object with input parameter values.")
parser.add_argument("--output-step", default="", help="Output STEP file path.")
parser.add_argument("--work-dir", default="", help="Runtime output directory.")
args = parser.parse_args(argv)
inputs = args.inputs
if inputs:
candidate = Path(inputs)
if candidate.is_file():
inputs = json.loads(candidate.read_text(encoding="utf-8-sig"))
else:
inputs = json.loads(inputs)
else:
inputs = {{}}
result = run(inputs=inputs, output_step=args.output_step or None, work_dir=args.work_dir or None)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
'''
def export_parametric_component(
*,
parameters: list[dict[str, str]],
edits: list[dict[str, object]],
source_step: Path | None,
component_root: Path | None = None,
component_name: str | None = None,
) -> Path:
if not parameters:
raise ValueError("No input parameters selected.")
root = component_root or default_component_root()
target_dir = next_component_dir(root, component_name or component_name_from_step(source_step))
target_dir.mkdir(parents=True, exist_ok=True)
component = {
"schema": COMPONENT_SCHEMA,
"createdAt": datetime.now().isoformat(timespec="seconds"),
"componentName": component_name or component_name_from_step(source_step),
"sourceStep": str(source_step or ""),
"parameters": parameters,
"edits": edits,
"outputName": "modified.step",
}
target = target_dir / "main.py"
target.write_text(render_component_main_py(component), encoding="utf-8")
return target
def _input_values(inputs: object) -> dict[str, object]:
if not isinstance(inputs, dict):
return {}
if isinstance(inputs.get("inputs"), dict):
return dict(inputs["inputs"])
rows = inputs.get("parameters")
if isinstance(rows, list):
result: dict[str, object] = {}
for row in rows:
if isinstance(row, dict) and row.get("name"):
result[str(row["name"])] = row.get("value", row.get("default", ""))
return result
return dict(inputs)
def _parse_vector3(value: object) -> list[float]:
vector = _as_list3(value)
if vector is None:
raise ValueError(f"Expected 3D vector value, got {value!r}.")
return vector
def _apply_arg_transform(value: object, transform: str, context: object) -> object:
context = context if isinstance(context, dict) else {}
if transform == "plane_target_position_to_offset":
current = float(context.get("current_plane_position"))
return float(value) - current
if transform == "radius_to_diameter":
return float(value) * 2.0
if transform == "diameter_to_radius":
return float(value) * 0.5
if transform == "degrees_to_radians":
import math
return math.radians(float(value))
if transform == "slot_open_angle_degrees_to_angular_span":
import math
return math.tau - math.radians(float(value))
if transform == "target_center_to_translation":
current = _parse_vector3(context.get("current_center"))
target = _parse_vector3(value)
return [target[index] - current[index] for index in range(3)]
return value
def _resolve_arg(value: object, values: dict[str, object], defaults: dict[str, object]) -> object:
if isinstance(value, dict) and "param" in value:
name = str(value.get("param") or "")
resolved = values.get(name, defaults.get(name, ""))
transform = str(value.get("transform") or "")
if transform:
return _apply_arg_transform(resolved, transform, value.get("context"))
return resolved
return value
def run_embedded_component(
component: dict[str, object],
*,
inputs: object | None = None,
output_step: str | Path | None = None,
work_dir: str | Path | None = None,
) -> dict[str, object]:
started = datetime.now().isoformat(timespec="seconds")
try:
source_step = Path(str(component.get("sourceStep") or "")).expanduser()
if not source_step.is_file():
return {"ok": False, "error": f"Source STEP does not exist: {source_step}", "startedAt": started}
output_path = Path(output_step) if output_step else None
if output_path is None:
output_root = Path(work_dir) if work_dir else Path.cwd()
output_path = output_root / str(component.get("outputName") or "modified.step")
output_path.parent.mkdir(parents=True, exist_ok=True)
parameters = [row for row in component.get("parameters", []) if isinstance(row, dict)]
defaults = {str(row.get("name") or ""): row.get("default", "") for row in parameters if row.get("name")}
values = _input_values(inputs)
model = StepModel.load(source_step)
messages: list[str] = []
for edit in component.get("edits", []):
if not isinstance(edit, dict):
continue
operation = str(edit.get("operation") or "")
args = [_resolve_arg(arg, values, defaults) for arg in list(edit.get("args") or [])]
messages.append(_execute(model, operation, args))
model.export_all(output_path)
return {
"ok": True,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"sourceStep": str(source_step),
"outputStep": str(output_path),
"messages": messages,
"parameters": parameters,
}
except Exception as exc:
return {
"ok": False,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"error": str(exc),
"traceback": traceback.format_exc(),
}
def _load_inputs(path_or_json: str) -> object:
if not path_or_json:
return {}
candidate = Path(path_or_json)
if candidate.is_file():
return json.loads(candidate.read_text(encoding="utf-8-sig"))
return json.loads(path_or_json)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run a STEP parametric component JSON.")
parser.add_argument("component", help="Component JSON file.")
parser.add_argument("--inputs", default="", help="JSON file or inline JSON object.")
parser.add_argument("--output-step", default="", help="Output STEP path.")
parser.add_argument("--work-dir", default="", help="Runtime work directory.")
parsed = parser.parse_args(argv)
try:
component = json.loads(Path(parsed.component).read_text(encoding="utf-8-sig"))
result = run_embedded_component(
component,
inputs=_load_inputs(parsed.inputs),
output_step=parsed.output_step or None,
work_dir=parsed.work_dir or None,
)
except Exception as exc:
result = {"ok": False, "error": str(exc), "traceback": traceback.format_exc()}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
+27 -1
View File
@@ -69,6 +69,32 @@ def _polydata_id_key(values: Iterable[int] | None) -> tuple[int, ...] | None:
return tuple(sorted({int(value) for value in values}))
def _display_edge_samples(edge: TopoDS_Shape, deflection: float) -> list[tuple[float, float, float]]:
try:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
except Exception:
return list(discretize_edge(edge, deflection))
if curve_type in {GeomAbs_Circle, GeomAbs_Ellipse} and math.isfinite(first) and math.isfinite(last):
span = abs(last - first)
if span > 1e-9:
min_segments = 24 if span >= math.tau * 0.75 else 8
segments = max(min_segments, int(math.ceil(span / math.radians(7.5))))
segments = min(max(segments, 2), 128)
samples: list[tuple[float, float, float]] = []
for index in range(segments + 1):
parameter = first + (last - first) * (index / segments)
point = curve.Value(parameter)
samples.append((float(point.X()), float(point.Y()), float(point.Z())))
if len(samples) >= 2:
return samples
return list(discretize_edge(edge, deflection))
class PolydataMixin:
def build_face_polydata(
self,
@@ -272,7 +298,7 @@ class PolydataMixin:
continue
if edge_id in hidden_edge_ids:
continue
samples = discretize_edge(edge, deflection)
samples = _display_edge_samples(edge, deflection)
if len(samples) < 2:
continue
polyline = vtk.vtkPolyLine()
+98 -8
View File
@@ -30,6 +30,7 @@ from .geometry_utils import (
_tuple_sub,
_vector_length,
)
from .parametric_component import component_name_from_step, default_component_root, export_parametric_component
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
@@ -122,6 +123,22 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
return "".join(parts)
def _start_background_thread(thread: QThread, priority: QThread.Priority = QThread.Priority.LowPriority) -> None:
try:
thread.start(priority)
except TypeError:
thread.start()
def _isolated_process_creation_flags() -> int:
if sys.platform != "win32":
return 0
flags = 0
for name in ("CREATE_NO_WINDOW", "BELOW_NORMAL_PRIORITY_CLASS"):
flags |= int(getattr(subprocess, name, 0))
return flags
class WindowActionMixin:
def _empty_edge_polydata(self):
polydata = vtk.vtkPolyData()
@@ -151,11 +168,49 @@ class WindowActionMixin:
except OSError as exc:
QMessageBox.warning(self, "导出参数失败", f"无法写入 {output_path.name}{exc}")
return
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数到 {output_path.name}")
component_path: Path | None = None
component_warning = ""
try:
component_path = self._export_parametric_component_main(rows)
except Exception as exc:
component_warning = str(exc)
if component_path is not None:
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数,并生成组件 {component_path.parent.name}")
else:
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数到 {output_path.name}")
if hasattr(self, "set_plain_info"):
names = "".join(str(row.get("displayName", "")) for row in rows[:8] if row.get("displayName"))
suffix = "……" if len(rows) > 8 else ""
self.set_plain_info(f"已导出参数文件:{output_path}\n参数数量:{len(rows)}\n参数:{names}{suffix}")
lines = [
f"已导出参数文件:{output_path}",
f"参数数量:{len(rows)}",
f"参数:{names}{suffix}",
]
if component_path is not None:
lines.extend(
[
f"已生成参数化组件:{component_path.parent}",
f"组件入口:{component_path.name}",
]
)
elif component_warning:
lines.append(f"组件生成未完成:{component_warning}")
self.set_plain_info("\n".join(lines))
def _export_parametric_component_main(self, rows: list[dict[str, str]]) -> Path:
edits = self._selected_parameter_component_edits(rows) if hasattr(self, "_selected_parameter_component_edits") else []
if not edits:
raise ValueError("当前勾选参数还不能映射为可执行 STEP 编辑操作。")
source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
if source_step is None or not source_step.is_file():
raise ValueError("当前模型缺少可复用的 STEP 文件路径,请先导入 STEP 模型。")
return export_parametric_component(
parameters=rows,
edits=edits,
source_step=source_step,
component_root=default_component_root(Path(__file__).resolve().parent.parent),
component_name=component_name_from_step(source_step),
)
def export_all(self) -> None:
if self.model is None:
@@ -488,7 +543,7 @@ class WindowActionMixin:
def action():
if keep_relations:
return self.model.push_pull_face_keep_relations(face_id, distance)
return self.model.push_pull_face(face_id, distance)
return self.model.push_pull_face(face_id, distance, plan=dict(plan))
self._run_edit_action(
action,
@@ -501,8 +556,11 @@ class WindowActionMixin:
"distance_rule": "positive=outward fuse, negative=inward cut",
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"plane_direction": plan.get("plane_direction"),
"current_plane_position": plan.get("current_plane_position"),
"target_plane_position": plan.get("target_plane_position"),
"current_plane_center": plan.get("current_plane_center"),
"target_plane_center": plan.get("target_plane_center"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"resize_strategy": plan.get("resize_strategy"),
@@ -1413,7 +1471,11 @@ class WindowActionMixin:
height_estimate = _float_or_none(info.get("hole_depth_estimate"))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = _float_or_none(info.get("angular_span"))
angular_span = _float_or_none(info.get("same_domain_angular_span"))
if angular_span is None:
angular_span = _float_or_none(info.get("angular_span"))
if bool(info.get("is_full_cylinder")):
angular_span = math.tau
if surface != "cylinder" or current_diameter is None or current_diameter <= 1e-9:
blockers.append("当前选中对象不是可识别的圆柱面,不能调整孔/槽直径。")
@@ -1588,6 +1650,7 @@ class WindowActionMixin:
"中心(局部重建)",
"中心(保持关系)",
"圆柱高度调整",
"圆柱孔径调整",
"高度(缩放特征)缩放所属对象",
}
@@ -1603,6 +1666,7 @@ class WindowActionMixin:
isolated_geometry_operations = {
"push_pull_face",
"push_pull_face_keep_relations",
"translate_face_plane_offset_owning",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
@@ -1615,6 +1679,8 @@ class WindowActionMixin:
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
"resize_cylindrical_boss_height",
"resize_cylindrical_boss",
"move_cylindrical_boss_axis",
"resize_cylindrical_height_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
@@ -1832,6 +1898,8 @@ class WindowActionMixin:
"does not have",
)
lower = text.lower()
if "target check failed" in lower or "没有到达目标" in text:
return "结果未达到目标", "几何内核生成了结果,但目标位置或目标尺寸校验没有通过,模型已回滚。"
if any(marker.lower() in lower for marker in unsupported_markers):
return "暂未实现", "当前版本暂未实现这类稳定修改。"
if any(marker.lower() in lower for marker in risk_markers):
@@ -7391,7 +7459,7 @@ class WindowActionMixin:
thread.finished.connect(self._forget_scan_thread)
self.scan_thread = thread
self.scan_worker = worker
thread.start()
_start_background_thread(thread)
def _run_scan_task_sync(self, action) -> None:
if not self.scan_in_progress:
@@ -7745,7 +7813,7 @@ class WindowActionMixin:
thread.finished.connect(self._forget_edit_thread)
self.edit_thread = thread
self.edit_worker = worker
thread.start()
_start_background_thread(thread)
def _edit_target_logical_id(self, target_kind: str | None, target_id: int | None) -> int | None:
if self.model is None or target_id is None or target_kind not in {"face", "feature"}:
@@ -7932,6 +8000,7 @@ class WindowActionMixin:
text=True,
encoding="utf-8",
errors="replace",
creationflags=_isolated_process_creation_flags(),
)
self.active_isolated_edit_process = process
stdout, stderr = process.communicate(timeout=timeout_seconds)
@@ -8107,16 +8176,29 @@ class WindowActionMixin:
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if target_position is not None and plane_direction is not None:
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
candidate_ids.extend(
self._face_ids_at_plane_position(
model,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
self._edit_integrity_int_or_none(parameters.get("part_id")),
self._edit_integrity_int_or_none(parameters.get("solid_id")),
part_id,
solid_id,
)
)
if solid_id is not None and solid_id >= 0:
candidate_ids.extend(
self._face_ids_at_plane_position(
model,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
part_id,
None,
)
)
for candidate_id in candidate_ids:
if not (0 <= int(candidate_id) < len(model.faces)):
continue
@@ -8470,6 +8552,10 @@ class WindowActionMixin:
for face_id in filtered(range(len(model.faces))):
if face_id not in result:
result.append(face_id)
if solid_id is not None and solid_id >= 0:
for face_id in filtered(range(len(model.faces)), require_solid=False):
if face_id not in result:
result.append(face_id)
return result
if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value:
result = filtered(range(len(model.faces)))
@@ -8921,6 +9007,8 @@ class WindowActionMixin:
if self.selected_kind is None:
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}")
if hasattr(self, "_after_property_edit_finished"):
self._after_property_edit_finished(success=True)
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
@@ -8940,6 +9028,8 @@ class WindowActionMixin:
self.statusBar().showMessage(
"后台编辑已取消,模型已保持在编辑前状态" if close_after_cancel else "不能修改,模型未修改"
)
if hasattr(self, "_after_property_edit_finished"):
self._after_property_edit_finished(success=False)
if close_after_cancel:
if self.edit_thread is not None and self.edit_thread.isRunning():
self.edit_thread.quit()
+27 -5
View File
@@ -1640,7 +1640,8 @@ class WindowCoreMixin:
if edge_ids:
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_polydata = self._cached_face_overlay_polydata(face_ids=[self.selected_face_id], smooth=False)
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_polydata = self._cached_edge_overlay_polydata(self.selected_edge_id)
@@ -1675,7 +1676,8 @@ class WindowCoreMixin:
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
self._highlight_faces(face_ids=[self.selected_face_id])
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
self._highlight_edge(self.selected_edge_id)
@@ -1871,6 +1873,7 @@ class WindowCoreMixin:
self.model_actor.GetProperty().SetSpecular(0.25)
self.model_actor.GetProperty().SetSpecularPower(18)
self.model_actor.GetProperty().SetInterpolationToPhong()
self.model_actor.GetProperty().EdgeVisibilityOff()
backface_property = vtk.vtkProperty()
backface_property.SetColor(0.58, 0.62, 0.64)
backface_property.SetDiffuse(0.9)
@@ -2623,7 +2626,7 @@ class WindowCoreMixin:
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
else:
highlight_face_ids = [face_id]
highlight_face_ids = self._selection_same_domain_face_ids(face_id) or [face_id]
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face" if feature_mode else "Face",
@@ -2643,6 +2646,21 @@ class WindowCoreMixin:
message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}"
self.statusBar().showMessage(self._selection_status(message, pick_position))
def _selection_same_domain_face_ids(self, face_id: int) -> list[int]:
if self.model is None:
return [face_id]
try:
face_ids = self.model.face_region_ids(face_id)
except Exception:
face_ids = []
if not face_ids:
try:
face_ids = self.model.connected_same_domain_face_ids(face_id)
except Exception:
face_ids = []
face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.model.faces)})
return face_ids or [face_id]
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
if self.model is None:
return
@@ -2872,6 +2890,7 @@ class WindowCoreMixin:
actor.GetProperty().SetSpecular(0.35)
actor.GetProperty().SetInterpolationToPhong()
actor.GetProperty().SetLineWidth(2)
actor.GetProperty().EdgeVisibilityOff()
self._offset_overlay_actor_toward_camera(actor, scale=0.00035)
self.highlight_actor = actor
self.highlight_signature = signature
@@ -2930,11 +2949,13 @@ class WindowCoreMixin:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "feature":
self._highlight_hover_faces(face_ids=[target_id])
face_ids = self._selection_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
else:
self._highlight_hover_faces(face_ids=[target_id])
face_ids = self._selection_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
self.hover_signature = signature
self.render_window.Render()
@@ -2957,6 +2978,7 @@ class WindowCoreMixin:
actor.GetProperty().SetInterpolationToPhong()
actor.GetProperty().LightingOff()
actor.GetProperty().SetLineWidth(2)
actor.GetProperty().EdgeVisibilityOff()
self._offset_overlay_actor_toward_camera(actor, scale=0.00055)
self.hover_face_actor = actor
self.renderer.AddActor(actor)
+429 -166
View File
@@ -26,6 +26,7 @@ from PySide6.QtGui import QColor
from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY
from .model import StepModel
from .parametric_component import component_edit_config_from_spec
from .records import OperationRecord
from .ui_helpers import * # noqa: F403
from .widgets import NoWheelComboBox
@@ -172,6 +173,11 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
surface = str(action_info.get("surface", "") or "")
feature_guess = str(action_info.get("feature_guess", "") or "")
angular_span = _float_or_none(action_info.get("angular_span"))
face_size_keys = (
("local_face_width", "local_face_height")
if bool(action_info.get("local_face_size_edit_ready"))
else ()
)
if surface == "plane":
if action_info.get("multistep_prismatic_status") == "blocked":
@@ -179,7 +185,8 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
if action_info.get("existing_chamfer_status") == "candidate":
return ("existing_chamfer_distance_estimate",)
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
keys = list(face_size_keys)
keys.append("face_target_normal_position")
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
if str(action_info.get("prismatic_feature_semantics") or "") in {"additive-boss", "subtractive-pocket"}:
@@ -187,15 +194,13 @@ def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return (
"local_face_width",
"local_face_height",
*face_size_keys,
"face_center_position",
"face_target_normal_position",
"shell_thickness_estimate",
)
return (
"local_face_width",
"local_face_height",
*face_size_keys,
"face_center_position",
"face_target_normal_position",
)
@@ -340,11 +345,27 @@ class WindowStateMixin:
title = f"{title_prefix} {logical_id}"
if logical_id != int(face_id):
title += f"(当前拓扑 Face {face_id}"
return {
result = {
"selection_title": title,
"selection_display_id": logical_id,
"selection_topological_face_id": int(face_id),
}
if self.model is not None:
try:
region_ids = self.model.face_region_ids(face_id)
except Exception:
region_ids = []
region_ids = sorted({int(item) for item in region_ids if 0 <= int(item) < len(self.model.faces)})
if len(region_ids) > 1:
result.update(
{
"face_region_logical_id": logical_id,
"face_region_ids": tuple(region_ids),
"face_region_count": len(region_ids),
"face_region_note": "STEP/B-Rep 将该局部面拆成多个同域拓扑 Face;界面按一个逻辑面区域显示,高亮包含全部碎片。",
}
)
return result
def _first_level_fact_selection_fields(self, face_id: int, scope: str = "auto") -> dict[str, object]:
if self.model is None:
@@ -553,6 +574,21 @@ class WindowStateMixin:
info.setdefault("feature_type", "可拉伸/切除平面候选")
info.setdefault("feature_edit_actions", "拉伸/切除平面")
elif surface == "cylinder":
same_domain_ids = []
try:
same_domain_ids = self.model.connected_same_domain_face_ids(face_id) or [face_id]
except Exception:
same_domain_ids = [face_id]
same_domain_ids = sorted({int(item) for item in same_domain_ids if 0 <= int(item) < len(self.model.faces)})
if same_domain_ids:
info["same_domain_face_ids"] = tuple(same_domain_ids)
info["same_domain_face_count"] = len(same_domain_ids)
info["feature_highlight_face_ids"] = tuple(same_domain_ids)
if len(same_domain_ids) > 1:
info.setdefault(
"same_domain_note",
"当前圆柱面在 STEP/B-Rep 中被拆成多个同域拓扑 Face,界面按一个局部孔/槽区域高亮。",
)
feature_guess = str(info.get("feature_guess", "") or "")
angular_span = _float_or_none(info.get("angular_span"))
if feature_guess == "hole/groove candidate":
@@ -768,7 +804,7 @@ class WindowStateMixin:
if self.load_in_progress:
self.statusBar().showMessage("STEP background loading is still running.")
return True
if self.operation_in_progress:
if self.operation_in_progress and not bool(getattr(self, "_property_batch_running_action", False)):
self.statusBar().showMessage(message)
return True
if self.scan_in_progress:
@@ -2004,7 +2040,7 @@ class WindowStateMixin:
)
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
input_item = self._property_table_item("", editable=False)
input_item.setToolTip("勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json")
input_item.setToolTip("勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py")
row_items = (label_item, current_item, scope_item, target_item, input_item)
self._style_property_row_items(row_items, editable=editable, spec=effective_spec)
for column, item in enumerate(row_items):
@@ -2191,7 +2227,7 @@ class WindowStateMixin:
checkbox.setChecked(False)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
tip = "勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json"
tip = "勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py"
if not exportable:
tip = "当前行不是可导出的尺寸输入参数。"
checkbox.setToolTip(tip)
@@ -2288,7 +2324,7 @@ class WindowStateMixin:
finally:
checkbox.blockSignals(was_blocked)
tip = (
"勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json"
"勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py"
if exportable
else "当前行不是可导出的尺寸输入参数。"
)
@@ -2442,16 +2478,21 @@ class WindowStateMixin:
prismatic_length = _float_or_none(action_info.get("prismatic_length"))
prismatic_width = _float_or_none(action_info.get("prismatic_width"))
prismatic_depth = _float_or_none(action_info.get("prismatic_extrusion_estimate"))
prismatic_reference_face_id = _int_or_none(action_info.get("prismatic_reference_face_id"))
part_solid_count = _int_or_none(action_info.get("part_solid_count"))
prismatic_size_supported = bool(
embedded_prismatic
and action_info.get("prismatic_profile_status") == "candidate"
and action_info.get("prismatic_extrusion_status") == "candidate"
and bool(action_info.get("local_face_size_edit_ready"))
and prismatic_length is not None
and prismatic_length > 0
and prismatic_width is not None
and prismatic_width > 0
and prismatic_depth is not None
and prismatic_depth > 0
and prismatic_reference_face_id is not None
and (part_solid_count is None or part_solid_count == 1)
and len(_int_values(action_info.get("prismatic_connected_side_face_ids"))) == 4
)
dimensions: list[dict[str, object]] = []
@@ -2470,14 +2511,16 @@ class WindowStateMixin:
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
if prismatic_size_key:
if not prismatic_size_supported:
continue
if prismatic_size_key and prismatic_size_supported:
modes = spec.get("scope_modes")
local_mode = modes.get("local") if isinstance(modes, dict) else None
local_mode = dict(local_mode) if isinstance(local_mode, dict) else {}
is_width_axis = key == "local_face_width"
current_limit = prismatic_width if is_width_axis else prismatic_length
size_blocker = str(
action_info.get("local_face_size_edit_blocker")
or "当前矩形特征的长宽、深度、参考面、单 Solid 条件或四个侧壁没有稳定识别,暂不开放长宽修改。"
)
local_mode.update(
{
"label": "局部重建",
@@ -2488,7 +2531,7 @@ class WindowStateMixin:
"输入矩形凸台/口袋的目标长度或宽度;程序会先移除/补回旧矩形包络,"
"再重建目标矩形特征。"
),
"disabled_tip": "当前矩形特征的长宽、深度或四个侧壁没有稳定识别,暂不开放长宽修改。",
"disabled_tip": size_blocker,
"range_hint": "当前版本不在一次编辑中交换长度和宽度方向。",
}
)
@@ -2752,6 +2795,8 @@ class WindowStateMixin:
show_generic_face_edit_specs = bool(has_face and (is_plane or is_shell_candidate) and not is_existing_chamfer)
local_face_deform_ready = bool(action_info.get("local_face_deform_ready", True))
local_face_deform_blocker = str(action_info.get("local_face_deform_blocker") or "").strip()
local_face_size_edit_ready = bool(action_info.get("local_face_size_edit_ready"))
local_face_size_edit_blocker = str(action_info.get("local_face_size_edit_blocker") or "").strip()
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
specs: list[dict[str, object]] = []
@@ -3553,147 +3598,150 @@ class WindowStateMixin:
current_face_width = _float_or_none(action_info.get("local_face_width"))
current_face_height = _float_or_none(action_info.get("local_face_height"))
face_size_tip = (
"这里的面内长度/面内宽度是选中 Face 在自身平面内两个主方向上的投影长度"
"不是面积,也不是模型整体高度。局部重建会移动当前 Face 顶点,相邻面按新顶点重建"
"面内长度/面内宽度是矩形平面特征的两个驱动尺寸"
"不是面积,也不是任意曲面的通用尺寸。局部重建会重建当前矩形特征,相邻面按新边界更新"
)
face_size_owner_tip = (
"这里的面内长度/面内宽度是选中 Face 在自身平面内两个主方向上的投影长度"
"不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
"面内长度/面内宽度是矩形平面特征的两个驱动尺寸"
"不是面积,也不是任意曲面的通用尺寸。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
)
add_scoped_spec(
key="local_face_width",
label="面内长度",
current_raw=current_face_width if current_face_width is not None else "",
target_text=numeric_text(current_face_width),
scope_default="local",
scope_modes={
"local": {
"label": "局部重建",
"action": "resize_face_width_local",
"target_attr": "face_width_input",
"enabled": bool(
local_face_deform_ready
and current_face_width is not None
and current_face_width > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面内长度;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内长度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
if local_face_size_edit_ready:
add_scoped_spec(
key="local_face_width",
label="面内长度",
current_raw=current_face_width if current_face_width is not None else "",
target_text=numeric_text(current_face_width),
scope_default="local",
scope_modes={
"local": {
"label": "局部重建",
"action": "resize_face_width_local",
"target_attr": "face_width_input",
"enabled": bool(
local_face_deform_ready
and current_face_width is not None
and current_face_width > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面内长度;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
local_face_size_edit_blocker
or "当前对象还没有识别为稳定矩形平面特征,不能修改面内长度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_width_keep_relations",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内长度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能保持关系地修改面内长度。"
),
"range_hint": (
"保持关系会把面内长度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}"
),
},
"owning": {
"label": "缩放特征",
"action": "resize_face_width_owning_scale",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面内长度;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}",
},
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_width_keep_relations",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内长度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能保持关系地修改面内长度。"
),
"range_hint": (
"保持关系会把面内长度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}"
),
value_type="positive",
used=("local_face_width", "local_face_width_direction", "local_face_size_center"),
**face_scale_limits(current_face_width),
)
add_scoped_spec(
key="local_face_height",
label="面内宽度",
current_raw=current_face_height if current_face_height is not None else "",
target_text=numeric_text(current_face_height),
scope_default="local",
scope_modes={
"local": {
"label": "局部重建",
"action": "resize_face_height_local",
"target_attr": "face_height_input",
"enabled": bool(
local_face_deform_ready
and current_face_height is not None
and current_face_height > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面内宽度;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
local_face_size_edit_blocker
or "当前对象还没有识别为稳定矩形平面特征,不能修改面内宽度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_height_keep_relations",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内宽度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能保持关系地修改面内宽度。"
),
"range_hint": (
"保持关系会把面内宽度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}"
),
},
"owning": {
"label": "缩放特征",
"action": "resize_face_height_owning_scale",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面内宽度;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}",
},
},
"owning": {
"label": "缩放特征",
"action": "resize_face_width_owning_scale",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面内长度;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}",
},
},
value_type="positive",
used=("local_face_width", "local_face_width_direction", "local_face_size_center"),
**face_scale_limits(current_face_width),
)
add_scoped_spec(
key="local_face_height",
label="面内宽度",
current_raw=current_face_height if current_face_height is not None else "",
target_text=numeric_text(current_face_height),
scope_default="local",
scope_modes={
"local": {
"label": "局部重建",
"action": "resize_face_height_local",
"target_attr": "face_height_input",
"enabled": bool(
local_face_deform_ready
and current_face_height is not None
and current_face_height > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面内宽度;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内宽度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_height_keep_relations",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内宽度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能保持关系地修改面内宽度。"
),
"range_hint": (
"保持关系会把面内宽度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}"
),
},
"owning": {
"label": "缩放特征",
"action": "resize_face_height_owning_scale",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面内宽度;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}",
},
},
value_type="positive",
used=("local_face_height", "local_face_height_direction", "local_face_size_center"),
**face_scale_limits(current_face_height),
)
value_type="positive",
used=("local_face_height", "local_face_height_direction", "local_face_size_center"),
**face_scale_limits(current_face_height),
)
add_scoped_spec(
key="face_center_position",
label="中心",
@@ -3773,11 +3821,7 @@ class WindowStateMixin:
)
if is_plane and not is_existing_chamfer:
plane_origin = _triple_or_none(action_info.get("plane_origin"))
plane_direction = (
_triple_or_none(action_info.get("push_pull_outward_direction"))
or _triple_or_none(action_info.get("normal"))
)
plane_origin, plane_direction = self._plane_offset_info_for_property(action_info)
current_plane_position = None
if plane_origin is not None and plane_direction is not None:
direction_length = math.sqrt(
@@ -3845,10 +3889,10 @@ class WindowStateMixin:
"action": "push_pull_face",
"target_attr": "offset_input",
"enabled": current_plane_position is not None,
"enabled_tip": "输入偏移的目标位置;程序会沿当前 Face 法向执行拉伸或切除。",
"enabled_tip": "输入偏移的目标位置;程序会沿判定的材料外侧法向执行拉伸或切除。",
"disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置拉伸/切除。",
"range_hint": (
"偏移是沿当前 Face 法向测量的目标位置,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。"
"偏移是沿程序判定的材料外侧法向测量的目标位置,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。"
"程序会把目标位置自动换算成本次拉伸/切除距离。"
f"{face_offset_hard_limit}"
),
@@ -3861,7 +3905,7 @@ class WindowStateMixin:
"target_attr": "offset_input",
"enabled": keep_relation_enabled,
"enabled_tip": (
"输入偏移的目标位置;程序会沿当前 Face 法向拉伸/切除,"
"输入偏移的目标位置;程序会沿判定的材料外侧法向拉伸/切除,"
"并要求一级相邻平面的平行/垂直关系可验证。"
),
"disabled_tip": keep_relation_disabled_tip,
@@ -3884,7 +3928,7 @@ class WindowStateMixin:
and current_face_center is not None
),
"enabled_tip": (
"输入偏移的目标位置;程序只把当前 Face 沿法向移动到该目标值,"
"输入偏移的目标位置;程序只把当前 Face 沿判定的材料外侧法向移动到该目标值,"
"并让相邻平面按新顶点重建。"
),
"disabled_tip": face_local_disabled_tip(
@@ -3908,7 +3952,7 @@ class WindowStateMixin:
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入偏移的目标位置;程序会沿当前 Face 法向平移所属特征或 Solid"
"输入偏移的目标位置;程序会沿判定的材料外侧法向平移所属特征或 Solid"
"当前面形状和所属对象内部尺寸不变。"
),
"disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按偏移移动所属特征。",
@@ -5863,12 +5907,19 @@ class WindowStateMixin:
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
if changed:
count = len(changed)
enabled_tip = (
"应用当前被修改的 1 个参数。"
if count == 1
else f"按表格顺序依次应用当前被修改的 {count} 个参数;失败时会停止后续修改。"
)
else:
enabled_tip = "应用当前被修改的参数。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用当前被修改的参数。",
enabled,
enabled_tip,
disabled_tip,
)
self._update_parameter_export_state(has_model)
@@ -5907,6 +5958,37 @@ class WindowStateMixin:
)
return rows
def _selected_parameter_component_edits(self, rows: list[dict[str, str]] | None = None) -> list[dict[str, object]]:
if not hasattr(self, "property_table"):
return []
specs = getattr(self, "property_editor_specs", [])
row_count = min(self.property_table.rowCount(), len(specs))
export_rows = list(rows or self._selected_parameter_export_rows())
edits: list[dict[str, object]] = []
export_index = 0
step_path = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
for row in range(row_count):
checkbox = self._property_input_checkbox(row)
if checkbox is None or not checkbox.isEnabled() or not checkbox.isChecked():
continue
if export_index >= len(export_rows):
break
spec = self._effective_property_spec(specs[row], row=row)
if str(spec.get("value_type", "number")) == "command":
continue
edit = component_edit_config_from_spec(
parameter_row=export_rows[export_index],
spec=spec,
selected_kind=str(getattr(self, "selected_kind", "") or ""),
selected_face_id=getattr(self, "selected_face_id", None),
selected_edge_id=getattr(self, "selected_edge_id", None),
step_path=step_path,
)
if edit is not None:
edits.append(edit)
export_index += 1
return edits
def _update_parameter_export_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "export_parameters_button"):
return
@@ -5922,7 +6004,7 @@ class WindowStateMixin:
self._set_control_state(
self.export_parameters_button,
bool(has_model and selected_rows),
f"导出已勾选的 {len(selected_rows)} 个输入参数到 data.json。",
f"导出已勾选的 {len(selected_rows)} 个输入参数到 data.json,并生成参数化组件 main.py",
disabled_tip,
)
@@ -5949,6 +6031,63 @@ class WindowStateMixin:
changed.append((row, effective_spec, text))
return changed
def _property_batch_item_from_row(self, row: int, spec: dict[str, object], text: str) -> dict[str, object]:
return {
"key": str(spec.get("key", "")),
"label": str(spec.get("label", "")),
"action": str(spec.get("action", "")),
"scope_key": str(spec.get("scope_key", self._property_scope_value(row, spec))),
"target_text": str(text),
"source_face_id": spec.get("source_face_id", self.selected_face_id),
"source_edge_id": spec.get("source_edge_id", self.selected_edge_id),
"selected_kind": self.selected_kind,
}
def _property_batch_row_for_item(self, item: dict[str, object]) -> int | None:
specs = getattr(self, "property_editor_specs", [])
target_key = str(item.get("key", ""))
target_label = str(item.get("label", ""))
target_action = str(item.get("action", ""))
target_scope = str(item.get("scope_key", ""))
for row, spec in enumerate(specs):
if target_key and str(spec.get("key", "")) != target_key:
continue
if target_label and str(spec.get("label", "")) != target_label:
continue
modes = spec.get("scope_modes")
mode: dict[str, object] | None = None
if target_scope and isinstance(modes, dict):
candidate_mode = modes.get(target_scope)
if not isinstance(candidate_mode, dict):
continue
mode = dict(candidate_mode)
effective = self._effective_property_spec(spec, row=row)
if target_action and str(effective.get("action", "")) != target_action:
scoped_action = str((mode or {}).get("action", ""))
if scoped_action != target_action:
continue
if target_scope and not isinstance(modes, dict):
if str(effective.get("scope_key", "")) not in {"", target_scope}:
continue
return row
return None
def _set_property_scope_value(self, row: int, scope_key: str) -> bool:
if not scope_key or not hasattr(self, "property_table"):
return True
widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN)
if not isinstance(widget, NoWheelComboBox):
return True
index = widget.findData(scope_key)
if index < 0:
index = widget.findText(scope_key)
if index < 0:
return False
if widget.currentIndex() == index:
return True
widget.setCurrentIndex(index)
return True
def _property_target_text(self, row: int) -> str:
if not hasattr(self, "property_table"):
return ""
@@ -6133,11 +6272,100 @@ class WindowStateMixin:
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。")
return
if len(changed) > 1:
QMessageBox.information(self, "一次执行一个修改", "请只修改一行目标值,然后再点击参数化建模。")
self._start_property_batch_edit(changed)
return
row, _spec, _text = changed[0]
self.apply_property_row_edit(row)
def _start_property_batch_edit(self, changed: list[tuple[int, dict[str, object], str]]) -> None:
if self._edit_busy("请等待当前编辑完成后再执行批量参数化建模。"):
return
items = [self._property_batch_item_from_row(row, spec, text) for row, spec, text in changed]
self.property_batch_queue = items
self.property_batch_total = len(items)
self.property_batch_done = 0
self.property_batch_active = True
self.statusBar().showMessage(f"开始批量参数化建模:共 {len(items)} 个参数。")
self._run_next_property_batch_item()
def _run_next_property_batch_item(self) -> None:
queue = list(getattr(self, "property_batch_queue", []) or [])
if not queue:
total = int(getattr(self, "property_batch_total", 0) or 0)
self._clear_property_batch_state()
self.statusBar().showMessage(f"批量参数化建模完成:已应用 {total} 个参数。")
self._update_property_apply_state()
return
item = queue.pop(0)
self.property_batch_queue = queue
row = self._property_batch_row_for_item(item)
if row is None:
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"找不到后续参数“{item.get('label', '')}”,模型拓扑可能已变化。已停止后续修改。",
)
self._update_property_apply_state()
return
if not self._set_property_scope_value(row, str(item.get("scope_key", ""))):
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"参数“{item.get('label', '')}”的建模意图已经不可用。已停止后续修改。",
)
self._update_property_apply_state()
return
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(target_widget, QLineEdit):
target_widget.setText(str(item.get("target_text", "")))
done = int(getattr(self, "property_batch_done", 0) or 0)
total = int(getattr(self, "property_batch_total", 0) or 0)
self.statusBar().showMessage(f"批量参数化建模:正在执行 {done + 1}/{total} - {item.get('label', '')}")
self._property_batch_running_action = True
self._property_batch_item_callback_seen = False
try:
self.apply_property_row_edit(row)
finally:
self._property_batch_running_action = False
if (
bool(getattr(self, "property_batch_active", False))
and not bool(getattr(self, "_property_batch_item_callback_seen", False))
and not bool(getattr(self, "operation_in_progress", False))
):
remaining = len(getattr(self, "property_batch_queue", []) or [])
done = int(getattr(self, "property_batch_done", 0) or 0)
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"参数“{item.get('label', '')}”没有启动可执行修改。已完成 {done} 个,剩余 {remaining} 个未执行。",
)
self._update_property_apply_state()
def _clear_property_batch_state(self) -> None:
self.property_batch_queue = []
self.property_batch_total = 0
self.property_batch_done = 0
self.property_batch_active = False
self._property_batch_running_action = False
self._property_batch_item_callback_seen = False
def _after_property_edit_finished(self, *, success: bool) -> None:
if not bool(getattr(self, "property_batch_active", False)):
return
self._property_batch_item_callback_seen = True
if not success:
remaining = len(getattr(self, "property_batch_queue", []) or [])
done = int(getattr(self, "property_batch_done", 0) or 0)
self._clear_property_batch_state()
self.statusBar().showMessage(f"批量参数化建模已停止:已完成 {done} 个,剩余 {remaining} 个未执行。")
self._update_property_apply_state()
return
self.property_batch_done = int(getattr(self, "property_batch_done", 0) or 0) + 1
QTimer.singleShot(0, self._run_next_property_batch_item)
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None or source_face_id == self.selected_face_id:
@@ -6453,6 +6681,41 @@ class WindowStateMixin:
return current_length * target_radius / current_radius
raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}")
def _plane_offset_info_for_property(
self,
action_info: dict[str, object],
) -> tuple[tuple[float, float, float] | None, tuple[float, float, float] | None]:
plane_origin = _triple_or_none(action_info.get("plane_origin"))
plane_direction = _triple_or_none(action_info.get("push_pull_outward_direction"))
selected_face_id = _int_or_none(action_info.get("feature_source_face_id"))
if selected_face_id is None:
selected_face_id = _int_or_none(action_info.get("topological_face_id"))
if selected_face_id is None:
selected_face_id = _int_or_none(action_info.get("face_id"))
model_faces = getattr(self.model, "faces", None) if self.model is not None else None
if (
self.model is not None
and model_faces is not None
and selected_face_id is not None
and 0 <= selected_face_id < len(model_faces)
and (plane_origin is None or plane_direction is None)
):
try:
full_info = self.model.face_info(selected_face_id)
except Exception:
full_info = {}
if plane_origin is None:
plane_origin = _triple_or_none(full_info.get("plane_origin"))
if plane_direction is None:
plane_direction = _triple_or_none(full_info.get("push_pull_outward_direction"))
if plane_direction is None:
plane_direction = _triple_or_none(full_info.get("oriented_normal"))
if plane_direction is None:
plane_direction = _triple_or_none(action_info.get("oriented_normal"))
if plane_direction is None:
plane_direction = _triple_or_none(action_info.get("normal"))
return plane_origin, plane_direction
def _transform_property_vector3_target(
self,
spec: dict[str, object],