feat: 推进一级关系参数化编辑与参数导出
This commit is contained in:
@@ -15,6 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
EXPECTED_FACE_ISOLATED_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",
|
||||
@@ -27,6 +28,8 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
|
||||
"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",
|
||||
@@ -232,6 +235,8 @@ def _face_property_actions_from_specs() -> set[str]:
|
||||
"bbox_center": (5.0, 5.0, 0.0),
|
||||
"local_face_width": 10.0,
|
||||
"local_face_height": 10.0,
|
||||
"local_face_size_edit_ready": True,
|
||||
"local_face_size_edit_blocker": "",
|
||||
"local_face_size_center": (5.0, 5.0, 0.0),
|
||||
"plane_origin": (0.0, 0.0, 0.0),
|
||||
"push_pull_outward_direction": (0.0, 0.0, 1.0),
|
||||
|
||||
@@ -55,6 +55,13 @@ def main() -> int:
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane"},
|
||||
(
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane", "local_face_size_edit_ready": True},
|
||||
(
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
@@ -64,6 +71,14 @@ def main() -> int:
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane", "shell_region_status": "candidate"},
|
||||
(
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
"shell_thickness_estimate",
|
||||
),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane", "shell_region_status": "candidate", "local_face_size_edit_ready": True},
|
||||
(
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
@@ -78,7 +93,16 @@ def main() -> int:
|
||||
"prismatic_profile_status": "candidate",
|
||||
"prismatic_extrusion_status": "candidate",
|
||||
},
|
||||
("local_face_width", "local_face_height", "shell_thickness_estimate"),
|
||||
("face_target_normal_position", "shell_thickness_estimate"),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "plane",
|
||||
"prismatic_profile_status": "candidate",
|
||||
"prismatic_extrusion_status": "candidate",
|
||||
"local_face_size_edit_ready": True,
|
||||
},
|
||||
("local_face_width", "local_face_height", "face_target_normal_position", "shell_thickness_estimate"),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "torus"},
|
||||
|
||||
@@ -59,7 +59,7 @@ def _verify_readme_mentions(readme: str) -> None:
|
||||
"[不能修改 -> 立即说明原因]",
|
||||
"[一级影响范围 -> 明确显示]",
|
||||
"Face 阶段的当前验收口径(R1 已收口)",
|
||||
"已验收:平面 Face 的 `面内长度`、`面内宽度`、`偏移`",
|
||||
"已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`",
|
||||
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
|
||||
"孔/槽阶段的当前验收口径(R2/R3 已收口)",
|
||||
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
|
||||
|
||||
@@ -70,6 +70,8 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("Smoke test", ("main.py", "--smoke-test")),
|
||||
("Property editor specs", ("verify_property_editor_specs.py",)),
|
||||
("Property table editor UI", ("verify_property_card_editor_ui.py",)),
|
||||
("Parametric component export", ("verify_parametric_component_export.py",)),
|
||||
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
|
||||
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
|
||||
("First-level fact graph", ("verify_first_level_fact_graph.py",)),
|
||||
("Associated feature probe and display budget", ("verify_associated_features.py",)),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.isolated_edit_worker import run_request
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.window_core import WindowCoreMixin
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
|
||||
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
|
||||
|
||||
|
||||
class _SelectionHarness(WindowCoreMixin, WindowStateMixin):
|
||||
def __init__(self, model: StepModel) -> None:
|
||||
self.model = model
|
||||
self.feature_detection_level = "current-only"
|
||||
self.operation_in_progress = False
|
||||
self.scan_in_progress = False
|
||||
self.load_in_progress = False
|
||||
self.selected_face_id = None
|
||||
self.selected_edge_id = None
|
||||
self.selected_kind = None
|
||||
self.selected_part_id = None
|
||||
self.selected_solid_id = None
|
||||
self.manual_bottom_face_id = None
|
||||
self.manual_slot_pair_face_id = None
|
||||
|
||||
def _current_feature_detection_level(self) -> str:
|
||||
return str(self.feature_detection_level)
|
||||
|
||||
def select_feature_face(self, face_id: int) -> None:
|
||||
self.selected_face_id = face_id
|
||||
self.selected_edge_id = None
|
||||
self.selected_kind = "feature"
|
||||
self.selected_part_id = self.model.face_part_ids[face_id]
|
||||
self.selected_solid_id = self.model.face_solid_ids[face_id]
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root: Path) -> str:
|
||||
input_path = root / "face87_input.brep"
|
||||
output_path = root / "face87_output.brep"
|
||||
request_path = root / "face87_resize_request.json"
|
||||
model.export_internal_brep(input_path)
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(output_path),
|
||||
"input_format": "brep",
|
||||
"output_format": "brep",
|
||||
"operation": "resize_cylindrical_hole",
|
||||
"args": [face_id, diameter],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
code = run_request(request_path)
|
||||
response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8"))
|
||||
_assert(code == 0 and response.get("ok") is True, f"isolated Face {face_id} resize should pass: {response}")
|
||||
_assert(output_path.exists(), f"isolated Face {face_id} resize should write output BREP")
|
||||
return str(response.get("message") or "")
|
||||
|
||||
|
||||
def _edge_ids_from_polydata(polydata) -> set[int]:
|
||||
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
|
||||
if edge_arr is None:
|
||||
return set()
|
||||
return {int(edge_arr.GetValue(index)) for index in range(edge_arr.GetNumberOfTuples())}
|
||||
|
||||
|
||||
def _polydata_edge_sample_counts(polydata) -> dict[int, list[int]]:
|
||||
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
|
||||
if edge_arr is None:
|
||||
return {}
|
||||
counts: dict[int, list[int]] = {}
|
||||
for cell_id in range(polydata.GetNumberOfCells()):
|
||||
edge_id = int(edge_arr.GetValue(cell_id))
|
||||
counts.setdefault(edge_id, []).append(int(polydata.GetCell(cell_id).GetNumberOfPoints()))
|
||||
return counts
|
||||
|
||||
|
||||
def _assert_default_edges_hide_same_domain_internal_edges(model: StepModel, label: str) -> None:
|
||||
visible_edge_ids = _edge_ids_from_polydata(model.build_edge_polydata(show_same_domain_internal_edges=False))
|
||||
hidden_internal_ids = set(model._same_domain_internal_edge_ids()) # noqa: SLF001
|
||||
leaked = sorted(visible_edge_ids & hidden_internal_ids)
|
||||
_assert(not leaked, f"{label} default edge display should hide same-domain/internal duplicate edges: {leaked[:12]}")
|
||||
|
||||
|
||||
def _assert_small_circle_edges_are_smooth(model: StepModel, label: str) -> None:
|
||||
edge_polydata = model.build_edge_polydata(deflection=1.6, show_same_domain_internal_edges=False)
|
||||
sample_counts = _polydata_edge_sample_counts(edge_polydata)
|
||||
rough_edges: list[tuple[int, int, float]] = []
|
||||
for edge_id, counts in sample_counts.items():
|
||||
info = model.edge_info(edge_id)
|
||||
if info.get("curve") != "circle":
|
||||
continue
|
||||
radius = float(info.get("radius") or 0.0)
|
||||
if radius <= 0.0 or radius > 0.35:
|
||||
continue
|
||||
point_count = max(counts or [0])
|
||||
if point_count < 9:
|
||||
rough_edges.append((edge_id, point_count, radius))
|
||||
_assert(not rough_edges, f"{label} small circular display edges should not be drawn as triangles: {rough_edges[:12]}")
|
||||
|
||||
|
||||
def _assert_rectangular_face_parameters(model: StepModel, harness: _SelectionHarness) -> None:
|
||||
face_id = 9
|
||||
info = model.feature_info(face_id)
|
||||
_assert(info.get("surface") == "plane", "ICEPAK Face 9 should be a planar rectangular face")
|
||||
_assert(info.get("prismatic_profile_status") == "candidate", "ICEPAK Face 9 should be a rectangular profile")
|
||||
_assert(bool(info.get("local_face_size_edit_ready")), f"ICEPAK Face 9 size parameters should be visible: {info}")
|
||||
width = float(info.get("local_face_width") or 0.0)
|
||||
height = float(info.get("local_face_height") or 0.0)
|
||||
_assert(abs(width - 8.0) <= 1e-7, f"ICEPAK Face 9 length should be read from rectangle edges, got {width:g}")
|
||||
_assert(abs(height - 4.9) <= 1e-7, f"ICEPAK Face 9 width should be read from rectangle edges, got {height:g}")
|
||||
|
||||
harness.select_feature_face(face_id)
|
||||
specs = harness._property_editor_specs(info, info)
|
||||
editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))}
|
||||
expected = {"local_face_width", "local_face_height", "face_target_normal_position"}
|
||||
missing = sorted(expected - editable_keys)
|
||||
_assert(not missing, f"ICEPAK Face 9 feature parameters should expose length, width and offset: {missing}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not MODEL_PATH.exists():
|
||||
print("icepak cylindrical region selection skipped: local ICEPAK-NATURAL.stp is not present")
|
||||
return 0
|
||||
|
||||
model = StepModel.load(MODEL_PATH)
|
||||
_assert_default_edges_hide_same_domain_internal_edges(model, "ICEPAK before edit")
|
||||
harness = _SelectionHarness(model)
|
||||
_assert_rectangular_face_parameters(model, harness)
|
||||
expected_regions = {
|
||||
87: [87, 94],
|
||||
94: [87, 94],
|
||||
84: [84, 97],
|
||||
97: [84, 97],
|
||||
}
|
||||
for face_id, expected in expected_regions.items():
|
||||
info = model.quick_face_info(face_id)
|
||||
_assert(info.get("surface") == "cylinder", f"Face {face_id} should be cylindrical")
|
||||
_assert(model.face_region_ids(face_id) == expected, f"Face {face_id} region should be {expected}")
|
||||
_assert(
|
||||
list(info.get("feature_highlight_face_ids") or []) == expected,
|
||||
f"Face {face_id} quick info highlight should include the whole same-domain region: {info.get('feature_highlight_face_ids')}",
|
||||
)
|
||||
_assert(
|
||||
harness._selection_same_domain_face_ids(face_id) == expected,
|
||||
f"Face {face_id} UI selection highlight should include the whole same-domain region",
|
||||
)
|
||||
context = harness._feature_context_info(face_id)
|
||||
_assert(
|
||||
list(context.get("feature_highlight_face_ids") or []) == expected,
|
||||
f"Face {face_id} feature context highlight should include the whole same-domain region: {context.get('feature_highlight_face_ids')}",
|
||||
)
|
||||
feature = model.feature_info(face_id)
|
||||
_assert(
|
||||
list(feature.get("same_domain_face_ids") or []) == expected,
|
||||
f"Face {face_id} feature info should use the whole same-domain region",
|
||||
)
|
||||
_assert(bool(feature.get("is_full_cylinder")), f"Face {face_id} should be treated as a full cylinder")
|
||||
|
||||
resize_model = StepModel.load(MODEL_PATH)
|
||||
plan = resize_model.cylindrical_resize_plan(87, 0.3)
|
||||
_assert(plan.get("feature_type") == "圆柱孔候选", "Face 87 should plan as a cylindrical hole")
|
||||
_assert(list(plan.get("same_domain_face_ids") or []) == [87, 94], "Face 87 resize plan should use both side fragments")
|
||||
_assert(bool(plan.get("is_full_cylinder")), "Face 87 resize plan should preserve full-cylinder semantics")
|
||||
_assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 87 resize plan should not use the selected half-face span")
|
||||
result = resize_model.resize_cylindrical_hole(87, 0.3)
|
||||
_assert("diameter 0.5 -> 0.3" in result, "Face 87 diameter shrink should complete")
|
||||
_assert_default_edges_hide_same_domain_internal_edges(resize_model, "ICEPAK after Face 87 shrink")
|
||||
_assert_small_circle_edges_are_smooth(resize_model, "ICEPAK after Face 87 shrink")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as temp_dir:
|
||||
worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir))
|
||||
_assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete")
|
||||
|
||||
print("icepak cylindrical region selection ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.GeomAbs import GeomAbs_Plane
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
from step_editor.isolated_edit_worker import run_request
|
||||
from step_editor.model import StepModel
|
||||
|
||||
|
||||
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
|
||||
return sum(left[index] * right[index] for index in range(3))
|
||||
|
||||
|
||||
def _plane_axis(model: StepModel, face_id: int) -> tuple[float, float, float]:
|
||||
surf = BRepAdaptor_Surface(model.faces[face_id])
|
||||
_assert(surf.GetType() == GeomAbs_Plane, f"Face {face_id} should be planar")
|
||||
direction = surf.Plane().Axis().Direction()
|
||||
return (float(direction.X()), float(direction.Y()), float(direction.Z()))
|
||||
|
||||
|
||||
def _isolated_push_pull(model: StepModel, face_id: int, distance: float, temp_dir: Path, stem: str) -> tuple[StepModel, str]:
|
||||
input_path = temp_dir / f"{stem}_input.brep"
|
||||
output_path = temp_dir / f"{stem}_output.brep"
|
||||
request_path = temp_dir / f"{stem}_request.json"
|
||||
model.export_internal_brep(input_path)
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(output_path),
|
||||
"input_format": "brep",
|
||||
"output_format": "brep",
|
||||
"operation": "push_pull_face",
|
||||
"args": [face_id, distance],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
code = run_request(request_path)
|
||||
response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8"))
|
||||
_assert(code == 0 and response.get("ok") is True, f"isolated Face {face_id} push-pull should pass: {response}")
|
||||
_assert(output_path.exists(), f"isolated Face {face_id} push-pull should write an output BREP")
|
||||
return StepModel.load_internal_brep(output_path), str(response.get("message") or "")
|
||||
|
||||
|
||||
def _ui_push_pull_context(model: StepModel, face_id: int, plan: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"operation_name": "拉伸/切除平面",
|
||||
"target": f"Face {face_id}",
|
||||
"target_kind": "face",
|
||||
"target_id": face_id,
|
||||
"target_logical_id": model.face_logical_id(face_id),
|
||||
"parameters": {
|
||||
"part_id": plan.get("part_id"),
|
||||
"solid_id": plan.get("solid_id"),
|
||||
"semantic_distance": plan.get("distance"),
|
||||
"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"),
|
||||
"edit_strategy_label": plan.get("edit_strategy_label"),
|
||||
"edit_semantics": plan.get("edit_semantics"),
|
||||
"push_pull_risk": plan.get("risk"),
|
||||
"push_pull_status": plan.get("status"),
|
||||
"push_pull_message": plan.get("message"),
|
||||
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
|
||||
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
|
||||
"push_pull_scope_note": plan.get("push_pull_scope_note"),
|
||||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||||
"selected_boundary_wires": plan.get("selected_boundary_wires"),
|
||||
"selected_inner_boundary_wires": plan.get("selected_inner_boundary_wires"),
|
||||
"selected_has_inner_boundaries": plan.get("selected_has_inner_boundaries"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not MODEL_PATH.exists():
|
||||
print("icepak face45 push-pull skipped: local ICEPAK-NATURAL.stp is not present")
|
||||
return 0
|
||||
|
||||
model = StepModel.load(MODEL_PATH)
|
||||
face_id = 45
|
||||
plan = model.push_pull_plan(face_id, 10.0)
|
||||
_assert(plan.get("status") != "blocked", f"Face 45 positive push-pull should be plannable: {plan}")
|
||||
_assert(float(plan.get("current_plane_position") or 0.0) == 5.0, f"Face 45 should use outward offset +5: {plan}")
|
||||
_assert(float(plan.get("target_plane_position") or 0.0) == 15.0, f"Face 45 target should be +15: {plan}")
|
||||
_assert(plan.get("target_plane_center") is not None, "Face push-pull plan should carry target_plane_center")
|
||||
|
||||
inward_plan = model.push_pull_plan(face_id, -10.0)
|
||||
_assert(inward_plan.get("status") == "blocked", f"Face 45 inward cut through material should be blocked: {inward_plan}")
|
||||
_assert("材料厚度" in str(inward_plan.get("blockers") or ""), f"blocked reason should mention material depth: {inward_plan}")
|
||||
|
||||
direct_model = StepModel.load(MODEL_PATH)
|
||||
direct_model.push_pull_face(face_id, 10.0)
|
||||
direct_followup_plan = direct_model.push_pull_plan(47, 10.0)
|
||||
direct_direction = tuple(float(item) for item in (direct_followup_plan.get("plane_direction") or ()))
|
||||
_assert(
|
||||
len(direct_direction) == 3 and abs(_dot(direct_direction, _plane_axis(direct_model, 47))) >= 0.92,
|
||||
f"Face 47 follow-up direction should stay aligned with its selected plane: {direct_followup_plan}",
|
||||
)
|
||||
direct_followup_message = direct_model.push_pull_face(47, 10.0, plan=dict(direct_followup_plan))
|
||||
direct_followup_target = float(direct_followup_plan.get("target_plane_position") or 0.0)
|
||||
_assert(
|
||||
f"target={direct_followup_target:g}" in direct_followup_message,
|
||||
f"direct second push-pull should match Face 47 target: {direct_followup_message}",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="icepak_face45_isolated_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
edited_model, message = _isolated_push_pull(model, face_id, 10.0, root, "face45")
|
||||
_assert("actual=15" in message and "target=15" in message, f"result check should match target face: {message}")
|
||||
|
||||
followup_face_id = 47
|
||||
followup_plan = edited_model.push_pull_plan(followup_face_id, 10.0)
|
||||
followup_context = _ui_push_pull_context(edited_model, followup_face_id, followup_plan)
|
||||
followup_current = float(followup_plan.get("current_plane_position") or 0.0)
|
||||
followup_target = float(followup_plan.get("target_plane_position") or 0.0)
|
||||
_assert(followup_plan.get("status") != "blocked", f"Face 47 follow-up should be plannable: {followup_plan}")
|
||||
_assert(
|
||||
abs(followup_target - followup_current - 10.0) <= 1e-8,
|
||||
f"Face 47 follow-up target should preserve the requested +10 offset: {followup_plan}",
|
||||
)
|
||||
_second_model, followup_message = _isolated_push_pull(edited_model, followup_face_id, 10.0, root, "face47")
|
||||
_assert(
|
||||
f"actual={followup_target:g}" in followup_message and f"target={followup_target:g}" in followup_message,
|
||||
f"second isolated push-pull should match Face 47 target: {followup_message}",
|
||||
)
|
||||
checker = WindowActionMixin()
|
||||
blocker = checker._face_target_integrity_blocker(_second_model, followup_context)
|
||||
_assert(blocker == "", f"UI target integrity check should accept the second push-pull: {blocker}")
|
||||
|
||||
print("icepak face45/face47 push-pull ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import py_compile
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.parametric_component import export_parametric_component
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_component_export_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source_step = root / "source.step"
|
||||
source_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
|
||||
parameters = [
|
||||
{
|
||||
"name": "面内长度",
|
||||
"displayName": "面内长度",
|
||||
"type": "number",
|
||||
"ioRole": "input",
|
||||
"default": "10",
|
||||
}
|
||||
]
|
||||
edits = [
|
||||
{
|
||||
"parameter": "面内长度",
|
||||
"displayName": "面内长度",
|
||||
"targetKind": "feature",
|
||||
"targetId": 0,
|
||||
"uiAction": "resize_face_width_local",
|
||||
"operation": "resize_face_size_local",
|
||||
"args": [0, {"param": "面内长度"}, "width"],
|
||||
"default": 10.0,
|
||||
"valueType": "positive",
|
||||
"scope": "local",
|
||||
"scopeLabel": "局部重建",
|
||||
"sourceStep": str(source_step),
|
||||
"parameterKey": "local_face_width",
|
||||
}
|
||||
]
|
||||
main_py = export_parametric_component(
|
||||
parameters=parameters,
|
||||
edits=edits,
|
||||
source_step=source_step,
|
||||
component_root=root / "nodes",
|
||||
component_name="测试组件",
|
||||
)
|
||||
text = main_py.read_text(encoding="utf-8")
|
||||
_assert("INPUT_PARAMETERS = " in text, "generated main.py should embed selected input parameter list")
|
||||
_assert("PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS" in text, "generated main.py should expose FlowEditor parameters")
|
||||
_assert("NODE_INFO = " in text, "generated main.py should expose FlowEditor node info")
|
||||
_assert("def execute(inputs, params, context):" in text, "generated main.py should expose FlowEditor execute entry")
|
||||
_assert("COMPONENT = " in text, "generated main.py should embed execution config")
|
||||
_assert("step_edit_config.json" not in text, "generated component should not require a sidecar config JSON")
|
||||
_assert(not (main_py.parent / "data.json").exists(), "component directory should not contain data.json")
|
||||
_assert(not (main_py.parent / "step_edit_config.json").exists(), "component directory should not contain step_edit_config.json")
|
||||
py_compile.compile(str(main_py), doraise=True)
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(compile(text, str(main_py), "exec"), namespace)
|
||||
embedded_input_parameters = namespace.get("INPUT_PARAMETERS")
|
||||
embedded_parameters = namespace.get("PARAMETERS")
|
||||
node_info = namespace.get("NODE_INFO")
|
||||
embedded_component = namespace.get("COMPONENT")
|
||||
_assert(embedded_input_parameters == parameters, f"embedded INPUT_PARAMETERS mismatch: {embedded_input_parameters}")
|
||||
_assert(isinstance(embedded_parameters, list), "embedded PARAMETERS should be a list")
|
||||
_assert(embedded_parameters[: len(parameters)] == parameters, f"embedded input parameter prefix mismatch: {embedded_parameters}")
|
||||
_assert(any(row.get("name") == "output_step" and row.get("ioRole") == "output" for row in embedded_parameters if isinstance(row, dict)), "generated PARAMETERS should include output_step output port")
|
||||
_assert(isinstance(node_info, dict), "NODE_INFO should be a dict")
|
||||
_assert(node_info.get("parameters") == embedded_parameters, "NODE_INFO should point to PARAMETERS")
|
||||
_assert(isinstance(embedded_component, dict), "embedded COMPONENT should be a dict")
|
||||
_assert(embedded_component.get("edits") == edits, f"embedded edits mismatch: {json.dumps(embedded_component, ensure_ascii=False)}")
|
||||
|
||||
print("parametric component export ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -67,7 +67,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
|
||||
self.selected_solid_id = None
|
||||
self.selected_face_id = 0
|
||||
self.selected_edge_id = None
|
||||
self.step_path = PROJECT_ROOT / "assets" / "models" / "probe.step"
|
||||
self.current_info_values = self._plane_info()
|
||||
self.executed_property_actions: list[tuple[str, str]] = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.object_edit_box = self
|
||||
@@ -91,6 +93,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
|
||||
self.current_capability_headline = QLabel()
|
||||
self.apply_property_button = QPushButton()
|
||||
self.export_parameters_button = QPushButton()
|
||||
self.face_width_input = QLineEdit()
|
||||
self.face_height_input = QLineEdit()
|
||||
|
||||
@staticmethod
|
||||
def _plane_info() -> dict[str, object]:
|
||||
@@ -101,6 +105,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
|
||||
"bbox_diagonal": 14.1421356237,
|
||||
"local_face_width": 10.0,
|
||||
"local_face_height": 10.0,
|
||||
"local_face_size_edit_ready": True,
|
||||
"local_face_size_edit_blocker": "",
|
||||
"plane_origin": (0.0, 0.0, 0.0),
|
||||
"push_pull_outward_direction": (0.0, 0.0, 1.0),
|
||||
"normal": (0.0, 0.0, 1.0),
|
||||
@@ -119,6 +125,14 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
|
||||
def _set_control_state(self, widget, enabled: bool, _enabled_tip: str, _disabled_tip: str) -> None:
|
||||
widget.setEnabled(enabled)
|
||||
|
||||
def resize_face_width_local(self) -> None:
|
||||
self.executed_property_actions.append(("resize_face_width_local", self.face_width_input.text()))
|
||||
self._after_property_edit_finished(success=True)
|
||||
|
||||
def resize_face_height_local(self) -> None:
|
||||
self.executed_property_actions.append(("resize_face_height_local", self.face_height_input.text()))
|
||||
self._after_property_edit_finished(success=True)
|
||||
|
||||
def statusBar(self) -> _StatusBar:
|
||||
return _StatusBar()
|
||||
|
||||
@@ -130,6 +144,7 @@ class _ActionMessageProbe(WindowActionMixin):
|
||||
class _ParameterExportActionProbe(WindowActionMixin):
|
||||
def __init__(self, output_path: Path) -> None:
|
||||
self.output_path = output_path
|
||||
self.component_path = output_path.parent / "nodes" / "000_test" / "main.py"
|
||||
self.status_bar = _StatusBarProbe()
|
||||
self.info_text = ""
|
||||
self.export_state_updates = 0
|
||||
@@ -155,6 +170,21 @@ class _ParameterExportActionProbe(WindowActionMixin):
|
||||
},
|
||||
]
|
||||
|
||||
def _export_parametric_component_main(self, rows: list[dict[str, str]]) -> Path:
|
||||
assert rows
|
||||
self.component_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
input_rows = json.dumps(rows, ensure_ascii=False, indent=4)
|
||||
self.component_path.write_text(
|
||||
"INPUT_PARAMETERS = "
|
||||
+ input_rows
|
||||
+ "\nOUTPUT_PARAMETERS = [{'name': 'output_step', 'displayName': '输出STEP', 'type': 'file', 'ioRole': 'output', 'default': ''}]"
|
||||
+ "\nPARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS"
|
||||
+ "\nNODE_INFO = {'parameters': PARAMETERS}"
|
||||
+ "\ndef execute(inputs, params, context):\n return {'output_step': 'modified.step'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return self.component_path
|
||||
|
||||
def _update_parameter_export_state(self) -> None:
|
||||
self.export_state_updates += 1
|
||||
|
||||
@@ -350,6 +380,16 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
|
||||
],
|
||||
f"unexpected parameter export payload: {selected_rows}",
|
||||
)
|
||||
component_edits = probe._selected_parameter_component_edits(selected_rows)
|
||||
_assert(len(component_edits) == 1, f"selected parameter should map to one component edit: {component_edits}")
|
||||
_assert(
|
||||
component_edits[0]["operation"] == "resize_face_size_local",
|
||||
f"face width export should map to backend resize operation: {component_edits[0]}",
|
||||
)
|
||||
_assert(
|
||||
component_edits[0]["args"] == [0, {"param": "面内长度"}, "width"],
|
||||
f"face width export should embed parameter placeholder args: {component_edits[0]}",
|
||||
)
|
||||
|
||||
target_widget.setText("12")
|
||||
probe._update_property_apply_state()
|
||||
@@ -357,6 +397,28 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
|
||||
_assert(any(row == target_row for row, _spec, _text in changed), "table target edit was not detected")
|
||||
_assert(probe.apply_property_button.isEnabled(), "single parametric modeling button should enable for one changed row")
|
||||
|
||||
width_row = _row_by_label(probe, "面内宽度")
|
||||
width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN)
|
||||
_assert(isinstance(width_widget, QLineEdit), "second editable table row should have a target editor")
|
||||
width_widget.setText("8")
|
||||
probe._update_property_apply_state()
|
||||
changed = probe._changed_property_rows()
|
||||
_assert(len(changed) >= 2, f"two target edits should be detected: {changed}")
|
||||
_assert(probe.apply_property_button.isEnabled(), "parametric modeling button should stay enabled for multiple changed rows")
|
||||
probe.apply_current_property_edit()
|
||||
for _index in range(6):
|
||||
QApplication.processEvents()
|
||||
if not getattr(probe, "property_batch_active", False):
|
||||
break
|
||||
_assert(
|
||||
probe.executed_property_actions == [
|
||||
("resize_face_width_local", "12"),
|
||||
("resize_face_height_local", "8"),
|
||||
],
|
||||
f"batch parametric modeling should execute changed rows in table order: {probe.executed_property_actions}",
|
||||
)
|
||||
_assert(not getattr(probe, "property_batch_active", False), "property batch state should clear after completion")
|
||||
|
||||
probe.toggle_property_table_expanded()
|
||||
_assert(probe.property_table_expanded, "property table expand toggle failed")
|
||||
_assert("收起" in probe.property_expand_button.text(), "expanded table button should offer to collapse")
|
||||
@@ -547,6 +609,7 @@ def _assert_parameter_export_action() -> None:
|
||||
probe = _ParameterExportActionProbe(output_path)
|
||||
probe.export_selected_parameters()
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
component_text = probe.component_path.read_text(encoding="utf-8")
|
||||
_assert(
|
||||
payload == [
|
||||
{
|
||||
@@ -566,8 +629,22 @@ def _assert_parameter_export_action() -> None:
|
||||
],
|
||||
f"parameter export action wrote unexpected JSON: {payload}",
|
||||
)
|
||||
_assert(probe.status_bar.messages and "已导出 2 个输入参数" in probe.status_bar.messages[-1], "parameter export status should mention exported count")
|
||||
_assert("data.json" in probe.info_text and "参数数量:2" in probe.info_text, "parameter export info panel summary should be useful")
|
||||
_assert(
|
||||
"INPUT_PARAMETERS" in component_text and "def execute(inputs, params, context):" in component_text and "面内长度" in component_text,
|
||||
"parameter export should generate FlowEditor-style component main.py with embedded input parameters",
|
||||
)
|
||||
_assert(
|
||||
not (probe.component_path.parent / "data.json").exists(),
|
||||
"component export should embed parameters in main.py instead of writing component data.json",
|
||||
)
|
||||
_assert(
|
||||
probe.status_bar.messages and "已导出 2 个输入参数" in probe.status_bar.messages[-1],
|
||||
"parameter export status should mention exported count",
|
||||
)
|
||||
_assert(
|
||||
"data.json" in probe.info_text and "参数数量:2" in probe.info_text and "组件入口:main.py" in probe.info_text,
|
||||
"parameter export info panel summary should mention generated component",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -27,6 +27,18 @@ class _PropertySpecProbe(WindowStateMixin):
|
||||
self.manual_slot_pair_face_id = None
|
||||
|
||||
|
||||
class _PlaneOffsetDirectionModel:
|
||||
faces = (object(),)
|
||||
|
||||
def face_info(self, face_id: int) -> dict[str, object]:
|
||||
if face_id != 0:
|
||||
raise ValueError(f"unexpected face id {face_id}")
|
||||
return {
|
||||
"plane_origin": (4.25, -5.0, 0.0),
|
||||
"push_pull_outward_direction": (0.0, -1.0, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _spec_keys(info: dict[str, object]) -> set[str]:
|
||||
return {str(spec.get("key", "")) for spec in _specs(info)}
|
||||
|
||||
@@ -332,6 +344,36 @@ def _assert_target_change_detection() -> None:
|
||||
raise SystemExit("changed vector Face target was not detected")
|
||||
|
||||
|
||||
def _assert_plane_offset_uses_push_pull_direction() -> None:
|
||||
probe = _PropertySpecProbe()
|
||||
probe.model = _PlaneOffsetDirectionModel()
|
||||
probe.selected_face_id = 0
|
||||
info = {
|
||||
"face_id": 0,
|
||||
"topological_face_id": 0,
|
||||
"surface": "plane",
|
||||
"plane_origin": (4.25, -5.0, 0.0),
|
||||
"normal": (0.0, 1.0, 0.0),
|
||||
"area_center": (4.25, -5.0, 2.5),
|
||||
"bbox_diagonal": 8.0,
|
||||
"local_face_deform_ready": True,
|
||||
"local_face_width": 1.5,
|
||||
"local_face_height": 4.0,
|
||||
}
|
||||
specs, _used = probe._editable_property_specs(info)
|
||||
offset_spec = _spec(specs, "face_target_normal_position")
|
||||
current = float(offset_spec.get("current_raw"))
|
||||
if abs(current - 5.0) > 1e-9:
|
||||
raise SystemExit(f"plane offset should use push-pull outward direction, got current={current:g}")
|
||||
push_pull = _scoped_effective_spec(specs, "face_target_normal_position", "push_pull")
|
||||
distance = probe._transform_property_scalar_target(push_pull, 5.0)
|
||||
if abs(distance) > 1e-9:
|
||||
raise SystemExit(f"unchanged plane offset target should convert to zero distance, got {distance:g}")
|
||||
inward_distance = probe._transform_property_scalar_target(push_pull, -5.0)
|
||||
if abs(inward_distance + 10.0) > 1e-9:
|
||||
raise SystemExit(f"opposite plane offset target should convert relative to outward current, got {inward_distance:g}")
|
||||
|
||||
|
||||
def _assert_property_table_column_widths() -> None:
|
||||
for width in (320, 340, 360, 400, 520):
|
||||
columns = _property_table_column_widths(width)
|
||||
@@ -369,9 +411,16 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
|
||||
"has_inner_boundaries": True,
|
||||
"local_face_deform_ready": False,
|
||||
"local_face_deform_blocker": "has inner boundary",
|
||||
"local_face_size_edit_ready": False,
|
||||
"local_face_size_edit_blocker": "has inner boundary",
|
||||
}
|
||||
)
|
||||
for key in ("local_face_width", "local_face_height", "face_center_position"):
|
||||
keys = {str(spec.get("key", "")) for spec in specs}
|
||||
for key in ("local_face_width", "local_face_height"):
|
||||
if key in keys:
|
||||
raise SystemExit(f"{key} should be hidden for a holed planar Face, got {keys}")
|
||||
|
||||
for key in ("face_center_position",):
|
||||
local_mode = _scope_mode(specs, key, "local")
|
||||
if bool(local_mode.get("enabled", True)):
|
||||
raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face")
|
||||
@@ -438,6 +487,19 @@ def main() -> int:
|
||||
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
|
||||
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
||||
}
|
||||
quick_plane_display_specs = _display_specs(plane_info)
|
||||
quick_plane_display_keys = {str(spec.get("key", "")) for spec in quick_plane_display_specs}
|
||||
if "local_face_width" in quick_plane_display_keys or "local_face_height" in quick_plane_display_keys:
|
||||
raise SystemExit(
|
||||
"quick plane Face should not show面内长度/面内宽度 until local size edit is explicitly ready: "
|
||||
f"{sorted(quick_plane_display_keys)}"
|
||||
)
|
||||
plane_info.update(
|
||||
{
|
||||
"local_face_size_edit_ready": True,
|
||||
"local_face_size_edit_blocker": "",
|
||||
}
|
||||
)
|
||||
plane_specs = _specs(plane_info)
|
||||
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
|
||||
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
|
||||
@@ -1217,6 +1279,7 @@ def main() -> int:
|
||||
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
|
||||
|
||||
_assert_target_change_detection()
|
||||
_assert_plane_offset_uses_push_pull_direction()
|
||||
_assert_property_table_column_widths()
|
||||
_assert_holed_plane_local_scopes_disabled()
|
||||
_assert_no_legacy_face_source_terms()
|
||||
|
||||
Reference in New Issue
Block a user