feat: 完善 Face 一级关系编辑和稳定性
This commit is contained in:
@@ -4,9 +4,11 @@ import os
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
project_root = os.path.abspath(os.path.join(SPECPATH, '..'))
|
||||
icon_path = os.path.join(project_root, 'assets', 'ico', 'logo_new.ico')
|
||||
datas = [(os.path.join(project_root, 'assets'), 'assets')]
|
||||
binaries = []
|
||||
hiddenimports = [
|
||||
'step_editor.isolated_edit_worker',
|
||||
'PySide6.QtCore',
|
||||
'PySide6.QtGui',
|
||||
'PySide6.QtWidgets',
|
||||
@@ -65,6 +67,7 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=icon_path,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
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.model import StepModel
|
||||
from step_editor.ui_helpers import _smooth_surface_polydata
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
|
||||
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
||||
SOURCE_FACE_ID = 398
|
||||
|
||||
|
||||
def main() -> int:
|
||||
model = StepModel.load(MODEL_PATH)
|
||||
root = model.feature_info(SOURCE_FACE_ID)
|
||||
if root.get("shell_region_status") == "candidate":
|
||||
raise AssertionError("Face 398 remote opposite plane was incorrectly exposed as wall thickness")
|
||||
|
||||
associated = model.associated_feature_infos(SOURCE_FACE_ID)
|
||||
by_type = {str(info.get("feature_type")): info for info in associated}
|
||||
for feature_type in ("凸台/外圆候选", "圆柱孔候选"):
|
||||
if feature_type not in by_type:
|
||||
raise AssertionError(f"missing associated {feature_type}: {tuple(by_type)}")
|
||||
if any(info.get("feature_type") == "规则矩形平面候选" for info in associated):
|
||||
raise AssertionError("unstable 2D-only rectangular candidates should not be associated")
|
||||
|
||||
boss = by_type["凸台/外圆候选"]
|
||||
hole = by_type["圆柱孔候选"]
|
||||
if int(boss.get("association_source_face_id", -1)) != 394:
|
||||
raise AssertionError(f"unexpected boss source: {boss.get('association_source_face_id')}")
|
||||
if int(hole.get("association_source_face_id", -1)) != 1591:
|
||||
raise AssertionError(f"unexpected hole source: {hole.get('association_source_face_id')}")
|
||||
if not bool(boss.get("is_full_cylinder")) or not bool(hole.get("is_full_cylinder")):
|
||||
raise AssertionError("split half-cylinder faces were not combined into full cylinders")
|
||||
|
||||
state = object.__new__(WindowStateMixin)
|
||||
state.model = model
|
||||
state.operation_in_progress = False
|
||||
state.scan_in_progress = False
|
||||
state.load_in_progress = False
|
||||
state.selected_face_id = SOURCE_FACE_ID
|
||||
state.selected_edge_id = None
|
||||
state.selected_kind = "feature"
|
||||
state.selected_part_id = int(root["part_id"])
|
||||
state.selected_solid_id = int(root["solid_id"])
|
||||
context = state._feature_context_info(SOURCE_FACE_ID)
|
||||
specs, _used = state._editable_property_specs(context)
|
||||
rows = state._feature_context_property_specs(specs, context)
|
||||
editable = {
|
||||
(str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action")))
|
||||
for row in rows
|
||||
if row.get("parameter_role") == "dimension" and row.get("source_face_id") is not None
|
||||
}
|
||||
expected = {
|
||||
("凸台/外圆候选 · 直径", 394, "resize_boss"),
|
||||
("凸台/外圆候选 · 高度", 394, "resize_boss_height"),
|
||||
("圆柱孔候选 · 直径", 1591, "resize_hole"),
|
||||
("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"),
|
||||
}
|
||||
if not expected.issubset(editable):
|
||||
raise AssertionError(f"missing associated editable rows: {expected - editable}")
|
||||
|
||||
display = _smooth_surface_polydata(model.build_face_polydata(deflection=0.035))
|
||||
face_ids = display.GetCellData().GetArray("face_id")
|
||||
normals = display.GetPointData().GetNormals()
|
||||
boss_cells = [
|
||||
index for index in range(display.GetNumberOfCells())
|
||||
if int(face_ids.GetTuple1(index)) == 394
|
||||
]
|
||||
if len(boss_cells) < 400:
|
||||
raise AssertionError(f"cylindrical display tessellation is too coarse: {len(boss_cells)}")
|
||||
plane_cells = [
|
||||
index for index in range(display.GetNumberOfCells())
|
||||
if int(face_ids.GetTuple1(index)) == SOURCE_FACE_ID
|
||||
]
|
||||
plane_points = sorted({
|
||||
display.GetCell(cell_id).GetPointId(point_index)
|
||||
for cell_id in plane_cells
|
||||
for point_index in range(display.GetCell(cell_id).GetNumberOfPoints())
|
||||
})
|
||||
plane_normals = [normals.GetTuple(point_id) for point_id in plane_points]
|
||||
reference = plane_normals[0]
|
||||
if min(sum(a * b for a, b in zip(reference, normal)) for normal in plane_normals) < 0.9999:
|
||||
raise AssertionError("planar B-Rep Face normals were polluted by adjacent faces")
|
||||
|
||||
print("associated feature and display quality ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,758 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, BRepPrimAPI_MakeCylinder
|
||||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
|
||||
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.model import StepModel
|
||||
from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
|
||||
class _ActionProbe(WindowActionMixin):
|
||||
def __init__(self, model: StepModel, path: Path) -> None:
|
||||
self.model = model
|
||||
self.step_path = path
|
||||
|
||||
|
||||
class _PropertyProbe(WindowStateMixin):
|
||||
def __init__(self, model: object | None = None, face_id: int = 0) -> None:
|
||||
self.model = object() if model is None else model
|
||||
self.operation_in_progress = False
|
||||
self.scan_in_progress = False
|
||||
self.load_in_progress = False
|
||||
self.selected_face_id = face_id
|
||||
self.selected_edge_id = None
|
||||
self.selected_kind = "feature"
|
||||
self.selected_part_id = 1
|
||||
self.selected_solid_id = 1
|
||||
self.manual_bottom_face_id = None
|
||||
self.manual_slot_pair_face_id = None
|
||||
|
||||
|
||||
def _write_one_degree_cone(path: Path) -> None:
|
||||
height = 10.0
|
||||
top_radius = 1.0
|
||||
bottom_radius = top_radius + height * math.tan(math.radians(1.0))
|
||||
_write_step(BRepPrimAPI_MakeCone(bottom_radius, top_radius, height).Shape(), path)
|
||||
|
||||
|
||||
def _write_one_degree_tip_cone(path: Path) -> None:
|
||||
height = 10.0
|
||||
bottom_radius = height * math.tan(math.radians(1.0))
|
||||
_write_step(BRepPrimAPI_MakeCone(bottom_radius, 0.0, height).Shape(), path)
|
||||
|
||||
|
||||
def _write_rotated_translated_cone(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape()
|
||||
rotate = gp_Trsf()
|
||||
rotate.SetRotation(gp_Ax1(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 1.0, 0.0)), math.radians(37.0))
|
||||
rotated = BRepBuilderAPI_Transform(shape, rotate, True).Shape()
|
||||
translate = gp_Trsf()
|
||||
translate.SetTranslation(gp_Vec(13.0, -4.0, 7.0))
|
||||
moved = BRepBuilderAPI_Transform(rotated, translate, True).Shape()
|
||||
_write_step(moved, path)
|
||||
|
||||
|
||||
def _write_embedded_countersink(path: Path) -> None:
|
||||
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
|
||||
cutter = BRepPrimAPI_MakeCone(
|
||||
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
2.0,
|
||||
5.0,
|
||||
5.0,
|
||||
).Shape()
|
||||
cut = BRepAlgoAPI_Cut(block, cutter)
|
||||
_write_step(_finalize_boolean_result(cut, "verify embedded countersink cone cut"), path)
|
||||
|
||||
|
||||
def _write_countersunk_through_hole(path: Path) -> None:
|
||||
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
|
||||
through_hole = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(15.0, 15.0, -0.5), gp_Dir(0.0, 0.0, 1.0)),
|
||||
2.0,
|
||||
11.0,
|
||||
).Shape()
|
||||
cut_hole = BRepAlgoAPI_Cut(block, through_hole)
|
||||
shape = _finalize_boolean_result(cut_hole, "verify countersunk through-hole cylinder cut")
|
||||
countersink = BRepPrimAPI_MakeCone(
|
||||
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
2.0,
|
||||
5.0,
|
||||
5.0,
|
||||
).Shape()
|
||||
cut_sink = BRepAlgoAPI_Cut(shape, countersink)
|
||||
_write_step(_finalize_boolean_result(cut_sink, "verify countersunk through-hole cone cut"), path)
|
||||
|
||||
|
||||
def _first_cone_face(model: StepModel) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == "cone":
|
||||
return face_id
|
||||
raise SystemExit("test model did not expose a cone Face")
|
||||
|
||||
|
||||
def _best_cone_face_by_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, dict[str, object], float]:
|
||||
best: tuple[int, dict[str, object], float] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "cone":
|
||||
continue
|
||||
angle = info.get("semi_angle")
|
||||
if not isinstance(angle, (int, float)):
|
||||
continue
|
||||
angle_degrees = abs(math.degrees(float(angle)))
|
||||
score = abs(angle_degrees - target_angle_degrees)
|
||||
if best is None or score < best[2]:
|
||||
best = (face_id, info, score)
|
||||
if best is None:
|
||||
raise SystemExit("no analytic cone Face was found")
|
||||
return best
|
||||
|
||||
|
||||
def _has_cylinder_diameter(model: StepModel, target_diameter: float, tolerance: float = 1e-4) -> bool:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
diameter = info.get("diameter")
|
||||
if isinstance(diameter, (int, float)) and abs(float(diameter) - target_diameter) <= tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _best_cone_angle_degrees(model: StepModel) -> float | None:
|
||||
best_angle: float | None = None
|
||||
for edited_face_id in range(len(model.faces)):
|
||||
info = model.face_info(edited_face_id)
|
||||
if info.get("surface") != "cone":
|
||||
continue
|
||||
angle = info.get("semi_angle")
|
||||
if not isinstance(angle, (int, float)):
|
||||
continue
|
||||
angle_degrees = abs(math.degrees(float(angle)))
|
||||
if best_angle is None or abs(angle_degrees - 50.0) < abs(best_angle - 50.0):
|
||||
best_angle = angle_degrees
|
||||
return best_angle
|
||||
|
||||
|
||||
def _unit(values: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
length = math.sqrt(sum(float(value) ** 2 for value in values))
|
||||
if length <= 1e-12:
|
||||
raise SystemExit(f"cannot normalize vector {values}")
|
||||
return tuple(float(value) / length for value in values)
|
||||
|
||||
|
||||
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
|
||||
return sum(float(left[index]) * float(right[index]) for index in range(3))
|
||||
|
||||
|
||||
def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
|
||||
return math.sqrt(sum((float(left[index]) - float(right[index])) ** 2 for index in range(3)))
|
||||
|
||||
|
||||
def _cone_semi_angle_spec_action() -> str:
|
||||
probe = _PropertyProbe()
|
||||
specs, _used = probe._editable_property_specs(
|
||||
{
|
||||
"surface": "cone",
|
||||
"reference_radius": 1.0,
|
||||
"reference_diameter": 2.0,
|
||||
"semi_angle": math.radians(1.0),
|
||||
"area": 10.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
}
|
||||
)
|
||||
for spec in specs:
|
||||
if spec.get("key") == "cone_semi_angle_degrees":
|
||||
if spec.get("target_transform"):
|
||||
raise SystemExit(f"cone semi-angle should not be converted through reference radius: {spec}")
|
||||
return str(spec.get("action") or "")
|
||||
raise SystemExit("cone semi-angle property spec was not found")
|
||||
|
||||
|
||||
def _cone_property_spec_for_model(model: StepModel, face_id: int, key: str) -> dict[str, object]:
|
||||
probe = _PropertyProbe(model, face_id)
|
||||
specs, _used = probe._editable_property_specs(model.face_info(face_id))
|
||||
for spec in specs:
|
||||
if spec.get("key") == key:
|
||||
return spec
|
||||
raise SystemExit(f"{key} property spec was not found for Face {face_id}")
|
||||
|
||||
|
||||
def _cone_semi_angle_spec_for_model(model: StepModel, face_id: int) -> dict[str, object]:
|
||||
return _cone_property_spec_for_model(model, face_id, "cone_semi_angle_degrees")
|
||||
|
||||
|
||||
def _verify_non_cap_reference_radius_rebuild(model: StepModel, plan: dict[str, object]) -> None:
|
||||
fake_plan = dict(plan)
|
||||
current_reference_radius = float(plan.get("current_reference_radius") or 0.0)
|
||||
scale = float(plan.get("affine_scale") or 0.0)
|
||||
if current_reference_radius <= 0 or scale <= 0:
|
||||
raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}")
|
||||
fake_current_reference_radius = current_reference_radius * 0.95
|
||||
fake_plan.update(
|
||||
{
|
||||
"current_reference_radius": fake_current_reference_radius,
|
||||
"target_reference_radius": fake_current_reference_radius * scale,
|
||||
"current_reference_diameter": fake_current_reference_radius * 2.0,
|
||||
"target_reference_diameter": fake_current_reference_radius * scale * 2.0,
|
||||
"resize_strategy": "radial-affine-scale-cone-semi-angle",
|
||||
"analytic_rebuild_available": False,
|
||||
}
|
||||
)
|
||||
model._annotate_simple_conical_rebuild_plan(fake_plan, "semi-angle")
|
||||
if not fake_plan.get("analytic_rebuild_available"):
|
||||
raise SystemExit(f"non-cap cone reference radius should still allow semi-angle rebuild: {fake_plan}")
|
||||
if fake_plan.get("analytic_cone_reference_radius_matches_current"):
|
||||
raise SystemExit(f"test setup should simulate a reference radius that is not a cap radius: {fake_plan}")
|
||||
if fake_plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
|
||||
raise SystemExit(f"non-cap cone reference rebuild chose wrong strategy: {fake_plan}")
|
||||
|
||||
model._apply_conical_analytic_rebuild_if_simple(fake_plan)
|
||||
best_angle = _best_cone_angle_degrees(model)
|
||||
if best_angle is None:
|
||||
raise SystemExit("non-cap reference rebuild should keep an analytic cone Face")
|
||||
if abs(best_angle - 50.0) > 0.5:
|
||||
raise SystemExit(f"non-cap reference rebuild produced {best_angle:g}deg instead of 50deg")
|
||||
|
||||
|
||||
def _verify_non_cap_reference_radius_direct_edit_is_blocked(model: StepModel, plan: dict[str, object]) -> None:
|
||||
fake_plan = dict(plan)
|
||||
current_reference_radius = float(plan.get("current_reference_radius") or 0.0)
|
||||
scale = float(plan.get("affine_scale") or 0.0)
|
||||
if current_reference_radius <= 0 or scale <= 0:
|
||||
raise SystemExit(f"cone plan is missing a valid scale/reference radius: {plan}")
|
||||
fake_current_reference_radius = current_reference_radius * 0.95
|
||||
fake_plan.update(
|
||||
{
|
||||
"status": "caution",
|
||||
"risk": "medium",
|
||||
"message": "",
|
||||
"warnings": "",
|
||||
"blockers": "",
|
||||
"current_reference_radius": fake_current_reference_radius,
|
||||
"target_reference_radius": fake_current_reference_radius * scale,
|
||||
"current_reference_diameter": fake_current_reference_radius * 2.0,
|
||||
"target_reference_diameter": fake_current_reference_radius * scale * 2.0,
|
||||
"resize_strategy": "radial-affine-scale-cone-reference-radius",
|
||||
"analytic_rebuild_available": False,
|
||||
}
|
||||
)
|
||||
model._annotate_simple_conical_rebuild_plan(fake_plan, "reference-radius")
|
||||
model._block_unstable_conical_reference_radius_plan(fake_plan)
|
||||
if fake_plan.get("status") != "blocked":
|
||||
raise SystemExit(f"non-cap direct reference-radius edit should be blocked: {fake_plan}")
|
||||
if fake_plan.get("resize_strategy") != "blocked-cone-reference-radius-non-cap":
|
||||
raise SystemExit(f"non-cap direct reference-radius edit chose wrong strategy: {fake_plan}")
|
||||
message = str(fake_plan.get("message") or "")
|
||||
if "简单圆锥" not in message or "锥孔/沉孔" not in message:
|
||||
raise SystemExit(f"non-cap direct reference-radius blocker should explain supported paths: {fake_plan}")
|
||||
|
||||
|
||||
def _verify_tip_cone_rebuild(path: Path) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
for key in ("cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"):
|
||||
spec = _cone_property_spec_for_model(model, face_id, key)
|
||||
if not spec.get("enabled"):
|
||||
raise SystemExit(f"simple cone {key} property should stay editable: {spec}")
|
||||
before = model.stats()
|
||||
plan = model.conical_semi_angle_plan(face_id, 50.0)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"tip cone semi-angle plan was blocked: {plan}")
|
||||
if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
|
||||
raise SystemExit(f"tip cone should use analytic rebuild, got {plan}")
|
||||
result = model.resize_conical_semi_angle(face_id, 50.0)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"tip cone edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
best_angle = _best_cone_angle_degrees(model)
|
||||
if best_angle is None:
|
||||
raise SystemExit(f"tip cone edit should keep an analytic cone Face: {result}")
|
||||
if abs(best_angle - 50.0) > 0.5:
|
||||
raise SystemExit(f"tip cone edit produced {best_angle:g}deg instead of 50deg")
|
||||
|
||||
|
||||
def _verify_rotated_cone_rebuild(path: Path) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
before_info = model.face_info(face_id)
|
||||
before_axis = _unit(before_info["axis"])
|
||||
before_axis_point = tuple(float(value) for value in before_info["axis_point"])
|
||||
before = model.stats()
|
||||
plan = model.conical_semi_angle_plan(face_id, 50.0)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"rotated cone semi-angle plan was blocked: {plan}")
|
||||
if plan.get("resize_strategy") != "analytic-cone-rebuild-semi-angle":
|
||||
raise SystemExit(f"rotated cone should use analytic rebuild, got {plan}")
|
||||
result = model.resize_conical_semi_angle(face_id, 50.0)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"rotated cone edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
|
||||
best_face: tuple[int, dict[str, object], float] | None = None
|
||||
for edited_face_id in range(len(model.faces)):
|
||||
info = model.face_info(edited_face_id)
|
||||
if info.get("surface") != "cone":
|
||||
continue
|
||||
angle = info.get("semi_angle")
|
||||
if not isinstance(angle, (int, float)):
|
||||
continue
|
||||
score = abs(abs(math.degrees(float(angle))) - 50.0)
|
||||
if best_face is None or score < best_face[2]:
|
||||
best_face = (edited_face_id, info, score)
|
||||
if best_face is None:
|
||||
raise SystemExit(f"rotated cone edit should keep an analytic cone Face: {result}")
|
||||
edited_info = best_face[1]
|
||||
after_axis = _unit(edited_info["axis"])
|
||||
if abs(_dot(before_axis, after_axis)) < 0.999:
|
||||
raise SystemExit(f"rotated cone edit changed the cone axis direction: before={before_axis}, after={after_axis}")
|
||||
after_axis_point = tuple(float(value) for value in edited_info["axis_point"])
|
||||
if _distance(before_axis_point, after_axis_point) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"rotated cone edit moved the cone axis point unexpectedly: before={before_axis_point}, after={after_axis_point}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_embedded_countersink_recut(path: Path, target_angle_degrees: float) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
before = model.stats()
|
||||
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"embedded countersink cone semi-angle plan was blocked: {plan}")
|
||||
if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle":
|
||||
raise SystemExit(f"embedded countersink cone should use bounded local recut, got {plan}")
|
||||
expected_mode = "enlarge" if target_angle_degrees > 30.963756532 else "shrink"
|
||||
if plan.get("embedded_cone_recut_mode") != expected_mode:
|
||||
raise SystemExit(f"embedded countersink cone recut mode mismatch: expected={expected_mode}, plan={plan}")
|
||||
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"embedded countersink edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, target_angle_degrees)
|
||||
angle_degrees = abs(math.degrees(float(edited_info["semi_angle"])))
|
||||
if abs(angle_degrees - target_angle_degrees) > 0.5:
|
||||
raise SystemExit(
|
||||
f"embedded countersink edit produced {angle_degrees:g}deg instead of {target_angle_degrees:g}deg"
|
||||
)
|
||||
circles = model._conical_face_circle_boundaries(
|
||||
edited_face_id,
|
||||
tuple(float(value) for value in edited_info["axis_point"]),
|
||||
_unit(tuple(float(value) for value in edited_info["axis"])),
|
||||
)
|
||||
radii = sorted(float(circle["radius"]) for circle in circles)
|
||||
if len(radii) != 2:
|
||||
raise SystemExit(f"embedded countersink edited cone should keep two circular boundaries: {circles}")
|
||||
target_large_radius = 2.0 + 5.0 * math.tan(math.radians(target_angle_degrees))
|
||||
if abs(radii[0] - 2.0) > 1e-4:
|
||||
raise SystemExit(f"embedded countersink should keep the small radius fixed at 2, got {radii}")
|
||||
if abs(radii[1] - target_large_radius) > 1e-3:
|
||||
raise SystemExit(f"embedded countersink large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}")
|
||||
if "embedded local cone recut" not in result:
|
||||
raise SystemExit(f"embedded countersink edit should report local cone recut: {result}")
|
||||
|
||||
|
||||
def _verify_countersunk_through_hole_recut(path: Path, target_angle_degrees: float) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
if not _has_cylinder_diameter(model, 4.0):
|
||||
raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole")
|
||||
before = model.stats()
|
||||
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"countersunk through-hole cone semi-angle plan was blocked: {plan}")
|
||||
if plan.get("resize_strategy") != "bounded-cone-recut-fixed-small-radius-semi-angle":
|
||||
raise SystemExit(f"countersunk through-hole cone should use bounded local recut, got {plan}")
|
||||
if "径向缩放" in str(plan.get("message") or ""):
|
||||
raise SystemExit(f"embedded local recut plan should not show radial-scaling copy: {plan}")
|
||||
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"countersunk through-hole edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
_best_cone_face_by_angle(model, target_angle_degrees)
|
||||
if not _has_cylinder_diameter(model, 4.0):
|
||||
raise SystemExit(f"countersunk through-hole edit should keep the lower 4mm cylindrical hole: {result}")
|
||||
|
||||
|
||||
def _verify_embedded_cone_reference_radius_recut(
|
||||
path: Path,
|
||||
target_reference_radius: float,
|
||||
*,
|
||||
expect_lower_cylinder: bool = False,
|
||||
) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0):
|
||||
raise SystemExit("countersunk through-hole test setup should expose the lower cylindrical hole")
|
||||
before = model.stats()
|
||||
before_info = model.face_info(face_id)
|
||||
current_angle_degrees = abs(math.degrees(float(before_info["semi_angle"])))
|
||||
plan = model.conical_reference_radius_plan(face_id, target_reference_radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"embedded cone reference-radius plan was blocked: {plan}")
|
||||
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
|
||||
raise SystemExit(f"embedded cone reference radius should use bounded local recut, got {plan}")
|
||||
expected_mode = "enlarge" if target_reference_radius > 2.0 else "shrink"
|
||||
if plan.get("embedded_cone_recut_mode") != expected_mode:
|
||||
raise SystemExit(f"embedded cone reference recut mode mismatch: expected={expected_mode}, plan={plan}")
|
||||
if "径向缩放" in str(plan.get("message") or ""):
|
||||
raise SystemExit(f"embedded reference recut plan should not show radial-scaling copy: {plan}")
|
||||
|
||||
result = model.resize_conical_reference_radius(face_id, target_reference_radius)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"embedded cone reference edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, current_angle_degrees)
|
||||
edited_angle = abs(math.degrees(float(edited_info["semi_angle"])))
|
||||
if abs(edited_angle - current_angle_degrees) > 0.5:
|
||||
raise SystemExit(
|
||||
f"embedded cone reference edit should preserve angle {current_angle_degrees:g}deg, got {edited_angle:g}deg"
|
||||
)
|
||||
circles = model._conical_face_circle_boundaries(
|
||||
edited_face_id,
|
||||
tuple(float(value) for value in edited_info["axis_point"]),
|
||||
_unit(tuple(float(value) for value in edited_info["axis"])),
|
||||
)
|
||||
radii = sorted(float(circle["radius"]) for circle in circles)
|
||||
if len(radii) != 2:
|
||||
raise SystemExit(f"embedded cone reference edit should keep two circular boundaries: {circles}")
|
||||
target_large_radius = target_reference_radius + 3.0
|
||||
if abs(radii[0] - target_reference_radius) > 1e-3:
|
||||
raise SystemExit(
|
||||
f"embedded cone reference edit small radius mismatch: got {radii[0]:g}, target={target_reference_radius:g}"
|
||||
)
|
||||
if abs(radii[1] - target_large_radius) > 1e-3:
|
||||
raise SystemExit(f"embedded cone reference edit large radius mismatch: got {radii[1]:g}, target={target_large_radius:g}")
|
||||
if expect_lower_cylinder and not _has_cylinder_diameter(model, 4.0):
|
||||
raise SystemExit(f"embedded cone reference edit should keep the lower 4mm cylindrical hole: {result}")
|
||||
if "reference radius resize completed by embedded local cone recut" not in result:
|
||||
raise SystemExit(f"embedded cone reference edit should report local cone recut: {result}")
|
||||
|
||||
|
||||
def _verify_embedded_cone_non_cap_reference_radius_recut(path: Path) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
info = model.face_info(face_id)
|
||||
current_reference_radius = 3.5
|
||||
target_reference_radius = 4.0
|
||||
plan = {
|
||||
"status": "caution",
|
||||
"risk": "medium",
|
||||
"message": "",
|
||||
"warnings": "",
|
||||
"blockers": "",
|
||||
"face_id": face_id,
|
||||
"part_id": int(model.face_part_ids[face_id]),
|
||||
"solid_id": int(model.face_solid_ids[face_id]),
|
||||
"surface": "cone",
|
||||
"current_reference_radius": current_reference_radius,
|
||||
"target_reference_radius": target_reference_radius,
|
||||
"axis_point": info.get("axis_point"),
|
||||
"axis": info.get("axis"),
|
||||
"resize_strategy": "radial-affine-scale-cone-reference-radius",
|
||||
"affine_scale": target_reference_radius / current_reference_radius,
|
||||
"affine_target_kind": "part",
|
||||
}
|
||||
model._annotate_embedded_conical_recut_plan(plan, "reference-radius")
|
||||
if not plan.get("embedded_cone_recut_available"):
|
||||
raise SystemExit(f"non-cap embedded reference radius should allow local recut: {plan}")
|
||||
if plan.get("embedded_cone_reference_matches_small") or plan.get("embedded_cone_reference_matches_large"):
|
||||
raise SystemExit(f"test setup should use a non-cap reference section: {plan}")
|
||||
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
|
||||
raise SystemExit(f"non-cap embedded reference should use bounded local recut, got {plan}")
|
||||
expected_small_radius = 2.5
|
||||
expected_large_radius = 5.5
|
||||
if abs(float(plan.get("embedded_cone_target_small_radius") or 0.0) - expected_small_radius) > 1e-6:
|
||||
raise SystemExit(f"non-cap embedded target small radius mismatch: {plan}")
|
||||
if abs(float(plan.get("embedded_cone_target_large_radius") or 0.0) - expected_large_radius) > 1e-6:
|
||||
raise SystemExit(f"non-cap embedded target large radius mismatch: {plan}")
|
||||
|
||||
before = model.stats()
|
||||
if not model._apply_embedded_conical_recut_if_available(plan):
|
||||
raise SystemExit(f"non-cap embedded reference recut was not applied: {plan}")
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"non-cap embedded reference edit changed solid count: before={before.solids}, after={after.solids}")
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(model, 30.963756532)
|
||||
circles = model._conical_face_circle_boundaries(
|
||||
edited_face_id,
|
||||
tuple(float(value) for value in edited_info["axis_point"]),
|
||||
_unit(tuple(float(value) for value in edited_info["axis"])),
|
||||
)
|
||||
radii = sorted(float(circle["radius"]) for circle in circles)
|
||||
if len(radii) != 2:
|
||||
raise SystemExit(f"non-cap embedded reference edit should keep two circular boundaries: {circles}")
|
||||
if abs(radii[0] - expected_small_radius) > 1e-3 or abs(radii[1] - expected_large_radius) > 1e-3:
|
||||
raise SystemExit(
|
||||
f"non-cap embedded reference edit boundary mismatch: got={radii}, "
|
||||
f"target={[expected_small_radius, expected_large_radius]}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_embedded_cone_feature_info_boundary_metrics(path: Path) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
info = model.feature_info(face_id)
|
||||
expected = {
|
||||
"feature_cone_small_radius": 2.0,
|
||||
"feature_cone_large_radius": 5.0,
|
||||
"feature_cone_height": 5.0,
|
||||
}
|
||||
for key, target in expected.items():
|
||||
value = info.get(key)
|
||||
if not isinstance(value, (int, float)) or abs(float(value) - target) > 1e-4:
|
||||
raise SystemExit(f"embedded cone feature info {key} mismatch: got={value}, target={target}")
|
||||
angle = info.get("feature_cone_boundary_half_angle_degrees")
|
||||
if not isinstance(angle, (int, float)) or abs(float(angle) - 30.963756532) > 1e-4:
|
||||
raise SystemExit(f"embedded cone feature info half-angle mismatch: {info}")
|
||||
if "半角" not in str(info.get("feature_edit_actions") or ""):
|
||||
raise SystemExit(f"cone feature edit actions should mention half-angle: {info}")
|
||||
|
||||
|
||||
def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cone_face(model)
|
||||
part_id = int(model.face_part_ids[face_id])
|
||||
plan = model.conical_reference_radius_plan(face_id, 3.0)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"embedded reference-radius isolation plan was blocked: {plan}")
|
||||
if str(plan.get("risk")) != "high":
|
||||
raise SystemExit(f"embedded reference-radius isolation case should be high risk, got {plan}")
|
||||
if plan.get("resize_strategy") != "bounded-cone-recut-preserve-angle-reference-radius":
|
||||
raise SystemExit(f"embedded reference-radius isolation should use local recut, got {plan}")
|
||||
|
||||
probe = _ActionProbe(model, path)
|
||||
isolation = probe._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, 3.0])
|
||||
if isolation is None:
|
||||
raise SystemExit(f"embedded reference-radius edit should request isolated execution: {plan}")
|
||||
|
||||
context = {
|
||||
"operation_name": "resize embedded cone reference radius",
|
||||
"target": f"Face {face_id}",
|
||||
"parameters": {
|
||||
"part_id": part_id,
|
||||
"surface": "cone",
|
||||
"target_reference_radius": plan.get("target_reference_radius"),
|
||||
"resize_strategy": plan.get("resize_strategy"),
|
||||
},
|
||||
"target_kind": "face",
|
||||
"target_id": face_id,
|
||||
"target_logical_id": model.face_region_logical_id(face_id),
|
||||
"pick_position": None,
|
||||
"show_same_domain_internal_edges": False,
|
||||
"edit_result_deflection": 2.4,
|
||||
}
|
||||
result = probe._run_isolated_edit_job(
|
||||
context=context,
|
||||
isolation=isolation,
|
||||
snapshot=model.snapshot(),
|
||||
before_stats=model.stats(),
|
||||
before_part_stats=model.part_topology_stats(part_id),
|
||||
before_quality=probe._edit_quality_info_or_none(model, context, part_id),
|
||||
before_geometry={},
|
||||
)
|
||||
if not result.get("message"):
|
||||
raise SystemExit("isolated embedded reference-radius edit did not return a completion message")
|
||||
if probe.model is model:
|
||||
raise SystemExit("successful isolated embedded reference-radius edit should load the edited model")
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(probe.model, 30.963756532)
|
||||
circles = probe.model._conical_face_circle_boundaries(
|
||||
edited_face_id,
|
||||
tuple(float(value) for value in edited_info["axis_point"]),
|
||||
_unit(tuple(float(value) for value in edited_info["axis"])),
|
||||
)
|
||||
radii = sorted(float(circle["radius"]) for circle in circles)
|
||||
if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-3 or abs(radii[1] - 6.0) > 1e-3:
|
||||
raise SystemExit(f"isolated embedded reference-radius edit produced wrong cone boundaries: {radii}")
|
||||
|
||||
|
||||
def _verify_complex_shallow_cone_half_angle_is_blocked() -> None:
|
||||
model_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
||||
model = StepModel.load(model_path)
|
||||
checked_face_ids: list[int] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.quick_face_info(face_id)
|
||||
if info.get("surface") != "cone":
|
||||
continue
|
||||
angle = info.get("semi_angle")
|
||||
reference_radius = info.get("reference_radius")
|
||||
if not isinstance(angle, (int, float)) or not isinstance(reference_radius, (int, float)):
|
||||
continue
|
||||
if abs(math.degrees(float(angle))) > 1.5 or float(reference_radius) < 1000.0:
|
||||
continue
|
||||
for target_angle in (10.0, 50.0):
|
||||
plan = model.conical_semi_angle_plan(face_id, target_angle)
|
||||
if plan.get("status") != "blocked" or plan.get("risk") != "blocked":
|
||||
raise SystemExit(
|
||||
f"complex shallow cone half-angle edit should be blocked for Face {face_id}, "
|
||||
f"target={target_angle:g}: {plan}"
|
||||
)
|
||||
if plan.get("resize_strategy") not in {
|
||||
"blocked-complex-cone-semi-angle-extreme-scale",
|
||||
"blocked-complex-cone-semi-angle-shallow-far-axis",
|
||||
}:
|
||||
raise SystemExit(f"complex shallow cone blocker chose wrong strategy: {plan}")
|
||||
message = str(plan.get("message") or "")
|
||||
if "复杂浅锥/拔模面" not in message or "无效 B-Rep" not in message:
|
||||
raise SystemExit(f"complex shallow cone blocker should explain the real risk: {plan}")
|
||||
spec = _cone_semi_angle_spec_for_model(model, face_id)
|
||||
if spec.get("enabled"):
|
||||
raise SystemExit(f"complex shallow cone semi-angle property should be disabled: {spec}")
|
||||
disabled_tip = str(spec.get("disabled_tip") or "")
|
||||
if (
|
||||
"复杂浅锥/拔模面" not in disabled_tip
|
||||
and "圆锥面/拔模面" not in disabled_tip
|
||||
) or "无效 B-Rep" not in disabled_tip:
|
||||
raise SystemExit(f"complex shallow cone disabled tip should explain the risk: {spec}")
|
||||
for key in ("cone_reference_radius", "cone_reference_diameter"):
|
||||
ref_spec = _cone_property_spec_for_model(model, face_id, key)
|
||||
if ref_spec.get("enabled"):
|
||||
raise SystemExit(f"complex shallow cone {key} property should be disabled: {ref_spec}")
|
||||
ref_tip = str(ref_spec.get("disabled_tip") or "")
|
||||
if (
|
||||
"复杂圆锥面/拔模面" not in ref_tip
|
||||
and "复杂浅锥/拔模面" not in ref_tip
|
||||
and "圆锥面/拔模面" not in ref_tip
|
||||
) or "无效 B-Rep" not in ref_tip:
|
||||
raise SystemExit(f"complex shallow cone {key} disabled tip should explain the risk: {ref_spec}")
|
||||
reference_plan = model.conical_reference_radius_plan(face_id, float(reference_radius) * 1.05)
|
||||
if reference_plan.get("status") != "blocked" or reference_plan.get("risk") != "blocked":
|
||||
raise SystemExit(f"complex shallow cone reference-radius edit should be blocked: {reference_plan}")
|
||||
if reference_plan.get("resize_strategy") not in {
|
||||
"blocked-complex-cone-reference-radius-unsupported-fallback",
|
||||
"blocked-complex-cone-reference-radius-shallow-far-axis",
|
||||
}:
|
||||
raise SystemExit(f"complex shallow cone reference-radius blocker chose wrong strategy: {reference_plan}")
|
||||
if 1490.0 <= float(reference_radius) <= 1510.0:
|
||||
start = time.perf_counter()
|
||||
user_plan = model.conical_reference_radius_plan(face_id, 1700.0)
|
||||
elapsed = time.perf_counter() - start
|
||||
if elapsed > 1.0:
|
||||
raise SystemExit(
|
||||
f"complex shallow cone reference-radius 1700 plan should be blocked quickly; "
|
||||
f"face={face_id}, elapsed={elapsed:.3f}s, plan={user_plan}"
|
||||
)
|
||||
if user_plan.get("status") != "blocked" or user_plan.get("risk") != "blocked":
|
||||
raise SystemExit(f"complex shallow cone reference-radius 1700 edit should be blocked: {user_plan}")
|
||||
checked_face_ids.append(face_id)
|
||||
if len(checked_face_ids) >= 3:
|
||||
break
|
||||
|
||||
if len(checked_face_ids) < 3:
|
||||
raise SystemExit(f"complex shallow cone half-angle guard found too few real-model cases: {checked_face_ids}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
action = _cone_semi_angle_spec_action()
|
||||
if action != "resize_cone_semi_angle":
|
||||
raise SystemExit(f"cone semi-angle property should call resize_cone_semi_angle, got {action!r}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cone_semi_angle_isolation_") as temp_dir:
|
||||
cone_path = Path(temp_dir) / "cone_1deg.step"
|
||||
tip_cone_path = Path(temp_dir) / "tip_cone_1deg.step"
|
||||
rotated_cone_path = Path(temp_dir) / "rotated_cone.step"
|
||||
countersink_path = Path(temp_dir) / "embedded_countersink.step"
|
||||
through_countersink_path = Path(temp_dir) / "countersunk_through_hole.step"
|
||||
_write_one_degree_cone(cone_path)
|
||||
_write_one_degree_tip_cone(tip_cone_path)
|
||||
_write_rotated_translated_cone(rotated_cone_path)
|
||||
_write_embedded_countersink(countersink_path)
|
||||
_write_countersunk_through_hole(through_countersink_path)
|
||||
_verify_tip_cone_rebuild(tip_cone_path)
|
||||
_verify_rotated_cone_rebuild(rotated_cone_path)
|
||||
_verify_embedded_countersink_recut(countersink_path, 50.0)
|
||||
_verify_embedded_countersink_recut(countersink_path, 20.0)
|
||||
_verify_embedded_cone_feature_info_boundary_metrics(countersink_path)
|
||||
_verify_countersunk_through_hole_recut(through_countersink_path, 50.0)
|
||||
_verify_countersunk_through_hole_recut(through_countersink_path, 20.0)
|
||||
_verify_embedded_cone_reference_radius_recut(countersink_path, 3.0)
|
||||
_verify_embedded_cone_reference_radius_recut(countersink_path, 1.5)
|
||||
_verify_embedded_cone_reference_radius_recut(through_countersink_path, 3.0, expect_lower_cylinder=True)
|
||||
_verify_embedded_cone_reference_radius_recut(through_countersink_path, 1.5, expect_lower_cylinder=True)
|
||||
_verify_embedded_cone_non_cap_reference_radius_recut(countersink_path)
|
||||
_verify_embedded_reference_radius_isolated_window_job(countersink_path)
|
||||
model = StepModel.load(cone_path)
|
||||
face_id = _first_cone_face(model)
|
||||
part_id = int(model.face_part_ids[face_id])
|
||||
solid_id = int(model.face_solid_ids[face_id])
|
||||
plan = model.conical_semi_angle_plan(face_id, 50.0)
|
||||
if str(plan.get("risk")) != "high":
|
||||
raise SystemExit(f"1deg -> 50deg cone semi-angle should be high risk, got {plan}")
|
||||
|
||||
fake_reference_model = StepModel.load(cone_path)
|
||||
_verify_non_cap_reference_radius_rebuild(fake_reference_model, dict(plan))
|
||||
fake_direct_reference_model = StepModel.load(cone_path)
|
||||
_verify_non_cap_reference_radius_direct_edit_is_blocked(fake_direct_reference_model, dict(plan))
|
||||
|
||||
probe = _ActionProbe(model, cone_path)
|
||||
isolation = probe._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, 50.0])
|
||||
if isolation is None:
|
||||
raise SystemExit(f"high-risk cone semi-angle edit should request isolated execution: {plan}")
|
||||
|
||||
context = {
|
||||
"operation_name": "修改圆锥半角(整体)",
|
||||
"target": f"Face {face_id}",
|
||||
"parameters": {
|
||||
"part_id": part_id,
|
||||
"solid_id": solid_id,
|
||||
"surface": "cone",
|
||||
"target_reference_radius": plan.get("target_reference_radius"),
|
||||
"target_semi_angle_degrees": plan.get("target_semi_angle_degrees"),
|
||||
"resize_strategy": plan.get("resize_strategy"),
|
||||
},
|
||||
"target_kind": "face",
|
||||
"target_id": face_id,
|
||||
"target_logical_id": model.face_region_logical_id(face_id),
|
||||
"pick_position": None,
|
||||
"show_same_domain_internal_edges": False,
|
||||
"edit_result_deflection": 2.4,
|
||||
}
|
||||
snapshot = model.snapshot()
|
||||
result = probe._run_isolated_edit_job(
|
||||
context=context,
|
||||
isolation=isolation,
|
||||
snapshot=snapshot,
|
||||
before_stats=model.stats(),
|
||||
before_part_stats=model.part_topology_stats(part_id),
|
||||
before_quality=probe._edit_quality_info_or_none(model, context, part_id),
|
||||
before_geometry={},
|
||||
)
|
||||
if not result.get("message"):
|
||||
raise SystemExit("isolated cone semi-angle edit did not return a completion message")
|
||||
if probe.model is model:
|
||||
raise SystemExit("successful isolated cone semi-angle edit should load the edited model")
|
||||
|
||||
edited_model = probe.model
|
||||
best_angle = _best_cone_angle_degrees(edited_model)
|
||||
if best_angle is None:
|
||||
raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face")
|
||||
if abs(best_angle - 50.0) > 0.5:
|
||||
raise SystemExit(f"isolated cone semi-angle edit produced {best_angle:g}deg instead of 50deg")
|
||||
|
||||
_verify_complex_shallow_cone_half_angle_is_blocked()
|
||||
|
||||
print("cone semi-angle isolated analytic rebuild ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
for path in (PROJECT_ROOT, SCRIPTS_DIR):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
|
||||
from verify_hole_resize import ( # noqa: E402
|
||||
_axis_center,
|
||||
_first_hole_face,
|
||||
_write_blind_hole_model,
|
||||
_write_through_hole_model,
|
||||
)
|
||||
from verify_slot_resize import ( # noqa: E402
|
||||
_first_slot_face,
|
||||
_slot_axis_center,
|
||||
_slot_metric,
|
||||
_write_half_round_slot_model,
|
||||
_write_obround_slot_model,
|
||||
)
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _assert_common_topology(topology: dict[str, object], label: str) -> None:
|
||||
_assert(topology.get("topology_relation_depth") == 1, f"{label}: topology depth should be 1")
|
||||
_assert(
|
||||
topology.get("topology_relation_boundary") == "shared-edge",
|
||||
f"{label}: topology boundary should be shared-edge",
|
||||
)
|
||||
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
|
||||
_assert("second-level" in ignored, f"{label}: second-level propagation should be explicitly ignored")
|
||||
_assert("third-level" in ignored, f"{label}: third-level propagation should be explicitly ignored")
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_side_face_count", 0) or 0) >= 1,
|
||||
f"{label}: selected cylindrical side region is missing",
|
||||
)
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) >= 1,
|
||||
f"{label}: cylindrical boundary edges are missing",
|
||||
)
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 1,
|
||||
f"{label}: direct adjacent Faces are missing",
|
||||
)
|
||||
|
||||
|
||||
def _assert_plan_topology(plan: dict[str, object], label: str) -> None:
|
||||
_assert(plan.get("topology_relation_depth") == 1, f"{label}: plan topology depth should be 1")
|
||||
_assert(
|
||||
plan.get("topology_relation_status") == "ready",
|
||||
f"{label}: plan topology should be ready, got {plan.get('topology_relation_status')!r}",
|
||||
)
|
||||
_assert(
|
||||
int(plan.get("first_level_boundary_edge_count", 0) or 0) >= 1,
|
||||
f"{label}: plan boundary edges are missing",
|
||||
)
|
||||
_assert(
|
||||
int(plan.get("first_level_adjacent_face_count", 0) or 0) >= 1,
|
||||
f"{label}: plan adjacent Faces are missing",
|
||||
)
|
||||
_assert(
|
||||
"second-level" in tuple(plan.get("topology_ignored_relation_depths", ()) or ()),
|
||||
f"{label}: plan should document ignored deeper topology",
|
||||
)
|
||||
_assert(
|
||||
plan.get("first_level_topology_status") == "ready",
|
||||
f"{label}: first-level topology guard should be ready",
|
||||
)
|
||||
_assert(
|
||||
plan.get("first_level_topology_guard_note"),
|
||||
f"{label}: first-level topology guard note is missing",
|
||||
)
|
||||
|
||||
|
||||
def _verify_through_hole() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_hole_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "through_hole.step"
|
||||
_write_through_hole_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_hole_face(model, blind=False)
|
||||
topology = model.cylindrical_feature_first_level_topology(face_id)
|
||||
_assert_common_topology(topology, "through hole")
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_end_face_count", 0) or 0) >= 1,
|
||||
"through hole: opening/end Faces should be part of first-level topology",
|
||||
)
|
||||
|
||||
center = _axis_center(model, face_id)
|
||||
diameter_plan = model.cylindrical_resize_plan(face_id, 8.0)
|
||||
axis_plan = model.cylindrical_axis_move_plan(face_id, (center[0] + 1.0, center[1], center[2]))
|
||||
suppress_plan = model.cylindrical_suppress_plan(face_id)
|
||||
_assert_plan_topology(diameter_plan, "through hole diameter plan")
|
||||
_assert_plan_topology(axis_plan, "through hole axis plan")
|
||||
_assert_plan_topology(suppress_plan, "through hole suppress plan")
|
||||
|
||||
|
||||
def _verify_blind_hole() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_blind_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "blind_hole.step"
|
||||
_write_blind_hole_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_hole_face(model, blind=True)
|
||||
topology = model.cylindrical_feature_first_level_topology(face_id)
|
||||
_assert_common_topology(topology, "blind hole")
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_bottom_face_count", 0) or 0) >= 1,
|
||||
"blind hole: bottom Face should be part of first-level topology",
|
||||
)
|
||||
|
||||
feature = model.feature_info(face_id)
|
||||
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()) or ())
|
||||
bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None
|
||||
depth_plan = model.cylindrical_depth_plan(face_id, 8.0, bottom_face_id=bottom_face_id)
|
||||
_assert_plan_topology(depth_plan, "blind hole depth plan")
|
||||
_assert(
|
||||
depth_plan.get("feature_bottom_face_ids"),
|
||||
"blind hole depth plan should keep bottom Face context",
|
||||
)
|
||||
|
||||
|
||||
def _verify_half_round_slot() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_slot_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "half_round_slot.step"
|
||||
_write_half_round_slot_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_slot_face(model)
|
||||
topology = model.cylindrical_feature_first_level_topology(face_id)
|
||||
_assert_common_topology(topology, "half-round slot")
|
||||
_assert(
|
||||
topology.get("slot_kind") == "partial-cylindrical-groove",
|
||||
"half-round slot: slot kind should be preserved",
|
||||
)
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_slot_boundary_face_count", 0) or 0) >= 1,
|
||||
"half-round slot: direct slot boundary Faces are missing",
|
||||
)
|
||||
|
||||
center = _slot_axis_center(model, face_id)
|
||||
width = _slot_metric(model, face_id, "slot_chord_width_estimate")
|
||||
depth = _slot_metric(model, face_id, "slot_sagitta_depth_estimate")
|
||||
arc_length = _slot_metric(model, face_id, "slot_arc_length_estimate")
|
||||
angular_span = _slot_metric(model, face_id, "slot_angular_span")
|
||||
plans = (
|
||||
("slot width plan", model.cylindrical_slot_resize_plan(face_id, width + 1.0, "width")),
|
||||
("slot depth plan", model.cylindrical_slot_resize_plan(face_id, depth + 0.5, "depth")),
|
||||
("slot arc plan", model.cylindrical_slot_resize_plan(face_id, arc_length + 0.5, "arc_length")),
|
||||
("slot angular plan", model.cylindrical_slot_angular_span_plan(face_id, max(0.25, angular_span * 0.8))),
|
||||
("slot axis plan", model.cylindrical_slot_axis_move_plan(face_id, (center[0], center[1] + 0.5, center[2]))),
|
||||
)
|
||||
for label, plan in plans:
|
||||
_assert_plan_topology(plan, label)
|
||||
|
||||
|
||||
def _verify_obround_slot_length_plan() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_obround_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "obround_slot.step"
|
||||
_write_obround_slot_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_slot_face(model)
|
||||
topology = model.cylindrical_feature_first_level_topology(face_id)
|
||||
_assert_common_topology(topology, "obround slot end")
|
||||
_assert(
|
||||
int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 2,
|
||||
"obround slot end: direct side-wall neighbors are missing",
|
||||
)
|
||||
|
||||
length_plan = model.cylindrical_slot_total_length_plan(face_id, 20.0)
|
||||
center_plan = model.cylindrical_slot_center_distance_plan(face_id, 14.0)
|
||||
_assert_plan_topology(length_plan, "obround slot total length plan")
|
||||
_assert_plan_topology(center_plan, "obround slot center distance plan")
|
||||
_assert(
|
||||
length_plan.get("slot_pair_face_id") not in {None, ""},
|
||||
"obround slot length plan should still expose the paired slot end",
|
||||
)
|
||||
|
||||
|
||||
def _verify_missing_first_level_neighbor_blocks_plan() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_guard_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "through_hole.step"
|
||||
_write_through_hole_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_hole_face(model, blind=False)
|
||||
original_topology = model.cylindrical_feature_first_level_topology
|
||||
|
||||
def broken_topology(target_face_id: int) -> dict[str, object]:
|
||||
topology = dict(original_topology(target_face_id))
|
||||
topology["cylindrical_feature_adjacent_face_ids"] = ()
|
||||
topology["cylindrical_feature_adjacent_face_count"] = 0
|
||||
topology["cylindrical_feature_first_level_face_ids"] = topology.get(
|
||||
"cylindrical_feature_side_face_ids",
|
||||
(target_face_id,),
|
||||
)
|
||||
topology["cylindrical_feature_first_level_face_count"] = int(
|
||||
topology.get("cylindrical_feature_side_face_count", 1) or 1
|
||||
)
|
||||
return topology
|
||||
|
||||
model.cylindrical_feature_first_level_topology = broken_topology # type: ignore[method-assign]
|
||||
plan = model.cylindrical_resize_plan(face_id, 8.0)
|
||||
_assert(plan.get("status") == "blocked", "missing first-level adjacent Faces should block diameter plan")
|
||||
_assert(
|
||||
plan.get("first_level_topology_status") == "blocked",
|
||||
"missing first-level adjacent Faces should mark topology guard blocked",
|
||||
)
|
||||
_assert(
|
||||
"相邻 Face" in str(plan.get("blockers") or plan.get("message") or ""),
|
||||
"blocked plan should explain the missing direct adjacent Faces",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_verify_through_hole()
|
||||
_verify_blind_hole()
|
||||
_verify_half_round_slot()
|
||||
_verify_obround_slot_length_plan()
|
||||
_verify_missing_first_level_neighbor_blocks_plan()
|
||||
print("cylindrical first-level topology verification passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
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.model import StepModel
|
||||
|
||||
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
||||
|
||||
|
||||
def _tuple3(value: object, label: str) -> tuple[float, float, float]:
|
||||
if not isinstance(value, (tuple, list)) or len(value) != 3:
|
||||
raise SystemExit(f"{label} is missing or invalid: {value!r}")
|
||||
return float(value[0]), float(value[1]), float(value[2])
|
||||
|
||||
|
||||
def _sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return a[0] - b[0], a[1] - b[1], a[2] - b[2]
|
||||
|
||||
|
||||
def _add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return a[0] + b[0], a[1] + b[1], a[2] + b[2]
|
||||
|
||||
|
||||
def _scale(a: tuple[float, float, float], factor: float) -> tuple[float, float, float]:
|
||||
return a[0] * factor, a[1] * factor, a[2] * factor
|
||||
|
||||
|
||||
def _length(a: tuple[float, float, float]) -> float:
|
||||
return (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) ** 0.5
|
||||
|
||||
|
||||
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
||||
return _length(_sub(a, b))
|
||||
|
||||
|
||||
def _first_line_edge_near_length(model: StepModel, length: float, tolerance: float) -> int:
|
||||
for edge_id in range(len(model.edges)):
|
||||
info = model.edge_info(edge_id)
|
||||
if info.get("curve") != "line":
|
||||
continue
|
||||
if abs(float(info.get("length") or 0.0) - length) <= tolerance:
|
||||
return edge_id
|
||||
raise SystemExit(f"no line Edge near length {length:g}")
|
||||
|
||||
|
||||
def _endpoint_pair_error(
|
||||
model: StepModel,
|
||||
edge_id: int,
|
||||
expected_start: tuple[float, float, float],
|
||||
expected_end: tuple[float, float, float],
|
||||
) -> float:
|
||||
info = model.edge_info(edge_id)
|
||||
start = _tuple3(info.get("start_point"), f"Edge {edge_id} start")
|
||||
end = _tuple3(info.get("end_point"), f"Edge {edge_id} end")
|
||||
direct = max(_distance(start, expected_start), _distance(end, expected_end))
|
||||
reversed_order = max(_distance(start, expected_end), _distance(end, expected_start))
|
||||
return min(direct, reversed_order)
|
||||
|
||||
|
||||
def _nearest_expected_edge(
|
||||
model: StepModel,
|
||||
expected_start: tuple[float, float, float],
|
||||
expected_end: tuple[float, float, float],
|
||||
) -> tuple[int, float, float]:
|
||||
best: tuple[int, float, float] | None = None
|
||||
target_length = _distance(expected_start, expected_end)
|
||||
for edge_id in range(len(model.edges)):
|
||||
info = model.edge_info(edge_id)
|
||||
if info.get("curve") != "line":
|
||||
continue
|
||||
endpoint_error = _endpoint_pair_error(model, edge_id, expected_start, expected_end)
|
||||
length_error = abs(float(info.get("length") or 0.0) - target_length)
|
||||
if best is None or (endpoint_error, length_error, edge_id) < (best[1], best[2], best[0]):
|
||||
best = (edge_id, endpoint_error, length_error)
|
||||
if best is None:
|
||||
raise SystemExit("edited model has no line Edge for endpoint verification")
|
||||
return best
|
||||
|
||||
|
||||
def _source_edge_frame(
|
||||
model: StepModel,
|
||||
source_length: float,
|
||||
tolerance: float,
|
||||
) -> tuple[int, tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], float]:
|
||||
edge_id = _first_line_edge_near_length(model, source_length, tolerance)
|
||||
info = model.edge_info(edge_id)
|
||||
start = _tuple3(info.get("start_point"), "source start")
|
||||
end = _tuple3(info.get("end_point"), "source end")
|
||||
vector = _sub(end, start)
|
||||
length = _length(vector)
|
||||
if length <= 1e-9:
|
||||
raise SystemExit(f"source Edge {edge_id} has zero length")
|
||||
direction = _scale(vector, 1.0 / length)
|
||||
return edge_id, start, end, direction, length
|
||||
|
||||
|
||||
def _run_endpoint_case(role: str, target_delta: float, tolerance: float) -> None:
|
||||
model = StepModel.load(DEFAULT_MODEL)
|
||||
edge_id, start, end, direction, source_length = _source_edge_frame(model, 10.0, tolerance)
|
||||
if role == "start":
|
||||
target_start = _add(start, _scale(direction, -target_delta))
|
||||
target_end = end
|
||||
target_point = target_start
|
||||
elif role == "end":
|
||||
target_start = start
|
||||
target_end = _add(end, _scale(direction, target_delta))
|
||||
target_point = target_end
|
||||
else:
|
||||
raise SystemExit(f"unsupported endpoint role: {role}")
|
||||
|
||||
before = model.stats()
|
||||
plan = model.edge_endpoint_move_plan(edge_id, role, target_point)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"{role} endpoint plan was blocked: {plan['message']}")
|
||||
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
|
||||
raise SystemExit(f"{role} endpoint should use local-edge-endpoint-deform, got {plan.get('resize_strategy')}")
|
||||
result = model.move_edge_endpoint(edge_id, role, target_point)
|
||||
after = model.stats()
|
||||
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"{role} endpoint changed solid count: before={before.solids}, after={after.solids}")
|
||||
if endpoint_error > tolerance or length_error > tolerance:
|
||||
raise SystemExit(
|
||||
f"{role} endpoint verification failed: matched_edge={matched_edge}, "
|
||||
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}"
|
||||
)
|
||||
|
||||
print(f"mode={role}_endpoint")
|
||||
print(f"source_edge={edge_id}")
|
||||
print(f"matched_edge={matched_edge}")
|
||||
print(f"source_length={source_length:.6f}")
|
||||
print(f"target_point={target_point}")
|
||||
print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_center_case(center_delta: tuple[float, float, float], tolerance: float) -> None:
|
||||
model = StepModel.load(DEFAULT_MODEL)
|
||||
edge_id, start, end, _direction, source_length = _source_edge_frame(model, 10.0, tolerance)
|
||||
current_center = _tuple3(model.edge_info(edge_id).get("length_center"), "source center")
|
||||
target_center = _add(current_center, center_delta)
|
||||
target_start = _add(start, center_delta)
|
||||
target_end = _add(end, center_delta)
|
||||
|
||||
before = model.stats()
|
||||
plan = model.edge_center_move_plan(edge_id, target_center)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"center plan was blocked: {plan['message']}")
|
||||
if plan.get("resize_strategy") != "local-edge-center-deform":
|
||||
raise SystemExit(f"center move should use local-edge-center-deform, got {plan.get('resize_strategy')}")
|
||||
result = model.move_edge_center(edge_id, target_center)
|
||||
after = model.stats()
|
||||
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"center move changed solid count: before={before.solids}, after={after.solids}")
|
||||
if endpoint_error > tolerance or length_error > tolerance:
|
||||
raise SystemExit(
|
||||
f"center move verification failed: matched_edge={matched_edge}, "
|
||||
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}"
|
||||
)
|
||||
|
||||
print("mode=center")
|
||||
print(f"source_edge={edge_id}")
|
||||
print(f"matched_edge={matched_edge}")
|
||||
print(f"source_length={source_length:.6f}")
|
||||
print(f"target_center={target_center}")
|
||||
print(f"endpoint_error={endpoint_error:.6g} length_error={length_error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify straight Edge start, center and end coordinate edits.")
|
||||
parser.add_argument("--mode", default="all", choices=["all", "start", "center", "end"])
|
||||
parser.add_argument("--endpoint-delta", type=float, default=3.0)
|
||||
parser.add_argument("--center-delta", default="0,0,2")
|
||||
parser.add_argument("--tolerance", type=float, default=1e-5)
|
||||
args = parser.parse_args()
|
||||
|
||||
center_delta = _tuple3([part.strip() for part in str(args.center_delta).split(",")], "center delta")
|
||||
if args.mode in {"all", "start"}:
|
||||
_run_endpoint_case("start", args.endpoint_delta, args.tolerance)
|
||||
if args.mode in {"all", "center"}:
|
||||
_run_center_case(center_delta, args.tolerance)
|
||||
if args.mode in {"all", "end"}:
|
||||
_run_endpoint_case("end", args.endpoint_delta, args.tolerance)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -54,6 +54,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"Edge length scale owning object, fixed center",
|
||||
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "center"),
|
||||
),
|
||||
(
|
||||
"Edge start, center and end coordinate edits",
|
||||
("verify_edge_coordinate_edit.py",),
|
||||
),
|
||||
(
|
||||
"Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize",
|
||||
("verify_edge_round_chamfer.py",),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, BRepPrimAPI_MakeSphere
|
||||
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
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.model_types import TopologyStats
|
||||
from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
|
||||
|
||||
class _Probe(WindowActionMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _face_context(strategy: str = "local-face-area-only-deform") -> dict[str, object]:
|
||||
return {
|
||||
"operation_name": "Face integrity guard probe",
|
||||
"target_kind": "face",
|
||||
"target_id": 0,
|
||||
"parameters": {"resize_strategy": strategy},
|
||||
}
|
||||
|
||||
|
||||
def _edge_context() -> dict[str, object]:
|
||||
return {
|
||||
"operation_name": "Edge integrity guard probe",
|
||||
"target_kind": "edge",
|
||||
"target_id": 0,
|
||||
"parameters": {"resize_strategy": "local-edge-length-deform"},
|
||||
}
|
||||
|
||||
|
||||
def _expect_blocked(label: str, callback) -> None:
|
||||
try:
|
||||
callback()
|
||||
except RuntimeError as exc:
|
||||
print(f"{label}: blocked as expected: {exc}")
|
||||
return
|
||||
raise SystemExit(f"{label}: expected a RuntimeError")
|
||||
|
||||
|
||||
def _expect_allowed(label: str, callback) -> list[str]:
|
||||
try:
|
||||
warnings = callback()
|
||||
except RuntimeError as exc:
|
||||
raise SystemExit(f"{label}: should have been allowed, got {exc}") from exc
|
||||
print(f"{label}: allowed, warnings={len(warnings)}")
|
||||
return warnings
|
||||
|
||||
|
||||
def _write_embedded_countersink(path: Path) -> None:
|
||||
block = BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape()
|
||||
cutter = BRepPrimAPI_MakeCone(
|
||||
gp_Ax2(gp_Pnt(15.0, 15.0, 5.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
2.0,
|
||||
5.0,
|
||||
5.0,
|
||||
).Shape()
|
||||
cut = BRepAlgoAPI_Cut(block, cutter)
|
||||
_write_step(_finalize_boolean_result(cut, "verify Face cone integrity countersink cut"), path)
|
||||
|
||||
|
||||
def _first_face_by_surface(model: StepModel, surface: str) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == surface:
|
||||
return face_id
|
||||
raise SystemExit(f"no {surface} Face was recognized")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
probe = _Probe()
|
||||
before = TopologyStats(parts=1, solids=1, faces=6, edges=12, vertices=8)
|
||||
after_ok = TopologyStats(parts=1, solids=1, faces=6, edges=12, vertices=8)
|
||||
after_no_solid = TopologyStats(parts=1, solids=0, faces=0, edges=0, vertices=0)
|
||||
after_two_solids = TopologyStats(parts=1, solids=2, faces=8, edges=16, vertices=12)
|
||||
before_quality_ok = {"quality_status": "ok", "brep_valid": True, "quality_warnings": ""}
|
||||
after_quality_ok = {"quality_status": "ok", "brep_valid": True, "quality_warnings": ""}
|
||||
after_quality_bad = {
|
||||
"quality_status": "warning",
|
||||
"brep_valid": False,
|
||||
"quality_warnings": "B-Rep invalid after edit",
|
||||
}
|
||||
before_quality_bad = {
|
||||
"quality_status": "warning",
|
||||
"brep_valid": False,
|
||||
"quality_warnings": "B-Rep invalid before edit",
|
||||
}
|
||||
|
||||
_expect_blocked(
|
||||
"Face edit losing solids",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
_face_context(),
|
||||
before,
|
||||
after_no_solid,
|
||||
before,
|
||||
after_no_solid,
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
),
|
||||
)
|
||||
_expect_blocked(
|
||||
"Face edit changing target solid count",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
_face_context(),
|
||||
before,
|
||||
after_two_solids,
|
||||
before,
|
||||
after_two_solids,
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
),
|
||||
)
|
||||
_expect_blocked(
|
||||
"New invalid B-Rep",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
_face_context(),
|
||||
before,
|
||||
after_ok,
|
||||
before,
|
||||
after_ok,
|
||||
before_quality_ok,
|
||||
after_quality_bad,
|
||||
),
|
||||
)
|
||||
warnings = _expect_allowed(
|
||||
"Already-warning B-Rep stays warning",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
_face_context(),
|
||||
before,
|
||||
after_ok,
|
||||
before,
|
||||
after_ok,
|
||||
before_quality_bad,
|
||||
after_quality_bad,
|
||||
),
|
||||
)
|
||||
if not warnings:
|
||||
raise SystemExit("existing quality warning should still be reported")
|
||||
cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
cube_stats = cube.stats()
|
||||
cube_part_id = int(cube.face_part_ids[0])
|
||||
cube_solid_id = int(cube.face_solid_ids[0])
|
||||
cube_context = {
|
||||
"operation_name": "Face target area integrity probe",
|
||||
"target_kind": "face",
|
||||
"target_id": 0,
|
||||
"target_logical_id": cube.face_region_logical_id(0),
|
||||
"parameters": {
|
||||
"resize_strategy": "local-face-area-only-deform",
|
||||
"part_id": cube_part_id,
|
||||
"solid_id": cube_solid_id,
|
||||
"surface": "plane",
|
||||
"target_area": 144.0,
|
||||
},
|
||||
}
|
||||
_expect_blocked(
|
||||
"Face target area mismatch",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
cube_context,
|
||||
cube_stats,
|
||||
cube_stats,
|
||||
cube.part_topology_stats(cube_part_id),
|
||||
cube.part_topology_stats(cube_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=cube,
|
||||
),
|
||||
)
|
||||
edited_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
edited_before_stats = edited_cube.stats()
|
||||
edited_before_part_stats = edited_cube.part_topology_stats(cube_part_id)
|
||||
edited_cube.resize_face_area_local(0, 144.0)
|
||||
_expect_allowed(
|
||||
"Face target area reached",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
cube_context,
|
||||
edited_before_stats,
|
||||
edited_cube.stats(),
|
||||
edited_before_part_stats,
|
||||
edited_cube.part_topology_stats(cube_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=edited_cube,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_sphere_") as temp_dir:
|
||||
sphere_path = Path(temp_dir) / "sphere.step"
|
||||
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path)
|
||||
sphere = StepModel.load(sphere_path)
|
||||
sphere_face_id = next(
|
||||
face_id for face_id in range(len(sphere.faces)) if sphere.face_info(face_id).get("surface") == "sphere"
|
||||
)
|
||||
sphere_part_id = int(sphere.face_part_ids[sphere_face_id])
|
||||
sphere_solid_id = int(sphere.face_solid_ids[sphere_face_id])
|
||||
sphere_context = {
|
||||
"operation_name": "Face target sphere radius integrity probe",
|
||||
"target_kind": "face",
|
||||
"target_id": sphere_face_id,
|
||||
"target_logical_id": sphere.face_region_logical_id(sphere_face_id),
|
||||
"parameters": {
|
||||
"resize_strategy": "sphere-radius-owning-scale",
|
||||
"part_id": sphere_part_id,
|
||||
"solid_id": sphere_solid_id,
|
||||
"surface": "sphere",
|
||||
"target_radius": 6.25,
|
||||
},
|
||||
}
|
||||
_expect_blocked(
|
||||
"Face target sphere radius mismatch",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
sphere_context,
|
||||
sphere.stats(),
|
||||
sphere.stats(),
|
||||
sphere.part_topology_stats(sphere_part_id),
|
||||
sphere.part_topology_stats(sphere_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=sphere,
|
||||
),
|
||||
)
|
||||
edited_sphere = StepModel.load(sphere_path)
|
||||
edited_sphere.resize_spherical_radius(sphere_face_id, 6.25)
|
||||
_expect_allowed(
|
||||
"Face target sphere radius reached",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
sphere_context,
|
||||
sphere.stats(),
|
||||
edited_sphere.stats(),
|
||||
sphere.part_topology_stats(sphere_part_id),
|
||||
edited_sphere.part_topology_stats(sphere_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=edited_sphere,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_cone_") as temp_dir:
|
||||
cone_path = Path(temp_dir) / "embedded_countersink.step"
|
||||
_write_embedded_countersink(cone_path)
|
||||
cone = StepModel.load(cone_path)
|
||||
cone_face_id = _first_face_by_surface(cone, "cone")
|
||||
cone_part_id = int(cone.face_part_ids[cone_face_id])
|
||||
cone_solid_id = int(cone.face_solid_ids[cone_face_id])
|
||||
stale_plane_id = 0
|
||||
if cone.face_info(stale_plane_id).get("surface") == "cone":
|
||||
raise SystemExit("cone integrity test expected Face 0 to be a stale non-cone candidate")
|
||||
cone_context = {
|
||||
"operation_name": "Face target cone boundary integrity probe",
|
||||
"target_kind": "face",
|
||||
"target_id": stale_plane_id,
|
||||
"target_logical_id": stale_plane_id,
|
||||
"parameters": {
|
||||
"resize_strategy": "bounded-cone-recut-preserve-angle-reference-radius",
|
||||
"part_id": cone_part_id,
|
||||
"solid_id": cone_solid_id,
|
||||
"surface": "cone",
|
||||
"target_reference_radius": 3.0,
|
||||
"embedded_cone_target_small_radius": 3.0,
|
||||
"embedded_cone_target_large_radius": 6.0,
|
||||
},
|
||||
}
|
||||
_expect_blocked(
|
||||
"Face target cone boundary mismatch",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
cone_context,
|
||||
cone.stats(),
|
||||
cone.stats(),
|
||||
cone.part_topology_stats(cone_part_id),
|
||||
cone.part_topology_stats(cone_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=cone,
|
||||
),
|
||||
)
|
||||
edited_cone = StepModel.load(cone_path)
|
||||
edited_before_stats = edited_cone.stats()
|
||||
edited_before_part_stats = edited_cone.part_topology_stats(cone_part_id)
|
||||
edited_cone.resize_conical_reference_radius(cone_face_id, 3.0)
|
||||
_expect_allowed(
|
||||
"Face target cone boundary reached despite stale id",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
cone_context,
|
||||
edited_before_stats,
|
||||
edited_cone.stats(),
|
||||
edited_before_part_stats,
|
||||
edited_cone.part_topology_stats(cone_part_id),
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
after_model=edited_cone,
|
||||
),
|
||||
)
|
||||
_expect_allowed(
|
||||
"Non-Face edit solid-count change only warns",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
_edge_context(),
|
||||
before,
|
||||
after_two_solids,
|
||||
before,
|
||||
after_two_solids,
|
||||
before_quality_ok,
|
||||
after_quality_ok,
|
||||
),
|
||||
)
|
||||
if probe._isolation_for_plan({"risk": "medium"}, "resize_face_area_local", [0, 125.0]) is None:
|
||||
raise SystemExit("medium-risk Face edits should use isolated execution")
|
||||
if probe._isolation_for_plan({"risk": "medium"}, "resize_edge_length", [0, 12.0]) is not None:
|
||||
raise SystemExit("unsupported medium-risk Edge edits should not use Face isolation")
|
||||
if probe._isolation_for_plan({"risk": "low"}, "resize_face_area_local", [0, 125.0]) is None:
|
||||
raise SystemExit("low-risk Face parameter edits should still use isolated execution")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "resize_edge_length", [0, 12.0]) is not None:
|
||||
raise SystemExit("unsupported high-risk Edge edits should not enter the Face isolation worker")
|
||||
|
||||
print("Face edit integrity guards ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,6 +14,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"face width, current face only",
|
||||
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
|
||||
),
|
||||
(
|
||||
"Face first-level shared-edge topology",
|
||||
("verify_face_first_level_topology.py",),
|
||||
),
|
||||
(
|
||||
"face width, owning feature",
|
||||
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"),
|
||||
@@ -118,6 +122,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"Face no-op plans are blocked",
|
||||
("verify_face_noop_guards.py",),
|
||||
),
|
||||
(
|
||||
"Face edit integrity guards reject broken results",
|
||||
("verify_face_edit_integrity_guards.py",),
|
||||
),
|
||||
(
|
||||
"cone semi-angle high-risk edit is isolated and rebuilt analytically",
|
||||
("verify_cone_semi_angle_isolation.py",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
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.model import StepModel
|
||||
from scripts.verify_property_editor_specs import _PropertySpecProbe, _spec
|
||||
|
||||
|
||||
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
||||
TOLERANCE = 1e-5
|
||||
|
||||
|
||||
def _center(info: dict[str, object]) -> tuple[float, float, float]:
|
||||
value = info.get("area_center") or info.get("bbox_center")
|
||||
if not isinstance(value, tuple) or len(value) != 3:
|
||||
raise SystemExit(f"Face has no stable center: {info}")
|
||||
return float(value[0]), float(value[1]), float(value[2])
|
||||
|
||||
|
||||
def _top_plane_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = _center(info)
|
||||
if best is None or center[2] > best[0]:
|
||||
best = (center[2], face_id)
|
||||
if best is None:
|
||||
raise SystemExit("No planar Face found.")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _plane_center_z_values(model: StepModel) -> list[float]:
|
||||
values: list[float] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
values.append(round(_center(info)[2], 6))
|
||||
return sorted(values)
|
||||
|
||||
|
||||
def _assert_close(actual: float, expected: float, label: str) -> None:
|
||||
if abs(float(actual) - float(expected)) > TOLERANCE:
|
||||
raise SystemExit(f"{label}: expected {expected:g}, got {actual:g}")
|
||||
|
||||
|
||||
def _assert_first_level_topology(model: StepModel, face_id: int) -> dict[str, object]:
|
||||
topology = model.face_first_level_topology(face_id)
|
||||
if topology.get("topology_relation_depth") != 1:
|
||||
raise SystemExit(f"Face topology depth should be 1, got {topology}")
|
||||
if topology.get("topology_relation_boundary") != "shared-edge":
|
||||
raise SystemExit(f"Face topology should use shared-edge boundary, got {topology}")
|
||||
if int(topology.get("same_domain_face_count", 0)) != 1:
|
||||
raise SystemExit(f"Cube top Face should be a single same-domain region, got {topology}")
|
||||
if int(topology.get("first_level_boundary_edge_count", 0)) != 4:
|
||||
raise SystemExit(f"Cube top Face should expose 4 first-level boundary Edges, got {topology}")
|
||||
if int(topology.get("first_level_boundary_vertex_count", 0)) != 4:
|
||||
raise SystemExit(f"Cube top Face should expose 4 first-level boundary Vertices, got {topology}")
|
||||
adjacent = tuple(topology.get("first_level_adjacent_face_ids", ()))
|
||||
if len(adjacent) != 4:
|
||||
raise SystemExit(f"Cube top Face should have 4 shared-edge adjacent Faces, got {topology}")
|
||||
first_level = set(int(item) for item in topology.get("first_level_face_ids", ()))
|
||||
if len(first_level) != 5 or face_id not in first_level:
|
||||
raise SystemExit(f"Cube top Face first-level set should contain selected Face + 4 side Faces, got {topology}")
|
||||
ignored = tuple(topology.get("topology_ignored_relation_depths", ()))
|
||||
if "second-level" not in ignored or "third-level" not in ignored:
|
||||
raise SystemExit(f"Face topology should explicitly leave deeper relations for later, got {topology}")
|
||||
return topology
|
||||
|
||||
|
||||
def _assert_plan_exposes_first_level(plan: dict[str, object], label: str) -> None:
|
||||
if plan.get("topology_relation_depth") != 1:
|
||||
raise SystemExit(f"{label} should expose topology_relation_depth=1, got {plan}")
|
||||
if int(plan.get("first_level_adjacent_face_count", 0)) != 4:
|
||||
raise SystemExit(f"{label} should expose 4 first-level adjacent Faces, got {plan}")
|
||||
if int(plan.get("first_level_boundary_edge_count", 0)) != 4:
|
||||
raise SystemExit(f"{label} should expose 4 first-level boundary Edges, got {plan}")
|
||||
if "二级" not in str(plan.get("topology_ignored_relation_note", "")):
|
||||
raise SystemExit(f"{label} should explain that second/third-level relations are not propagated yet, got {plan}")
|
||||
|
||||
|
||||
def _assert_selection_exposes_first_level(model: StepModel, face_id: int) -> None:
|
||||
probe = _PropertySpecProbe()
|
||||
probe.model = model
|
||||
probe.selected_face_id = face_id
|
||||
probe.selected_kind = "feature"
|
||||
quick_info = model.quick_face_info(face_id)
|
||||
feature_info = probe._feature_info_for_selected_face(face_id, quick_info)
|
||||
probe.selected_part_id = int(feature_info.get("part_id", 1))
|
||||
solid_id = int(feature_info.get("solid_id", -1))
|
||||
probe.selected_solid_id = solid_id if solid_id >= 0 else None
|
||||
|
||||
if int(feature_info.get("topology_relation_depth", 0) or 0) != 1:
|
||||
raise SystemExit(f"Selected Face feature info should expose first-level topology, got {feature_info}")
|
||||
if int(feature_info.get("first_level_adjacent_face_count", 0) or 0) != 4:
|
||||
raise SystemExit(f"Selected Face should expose 4 first-level adjacent Faces, got {feature_info}")
|
||||
|
||||
editable_specs, _used = probe._editable_property_specs(feature_info)
|
||||
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
|
||||
topology_row = _spec(feature_rows, "face_first_level_topology")
|
||||
topology_text = str(topology_row.get("current_text") or "")
|
||||
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
|
||||
if fragment not in topology_text:
|
||||
raise SystemExit(f"Selected Face topology row is not clear enough: {topology_row}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
model = StepModel.load(DEFAULT_MODEL)
|
||||
face_id = _top_plane_face(model)
|
||||
topology = _assert_first_level_topology(model, face_id)
|
||||
_assert_selection_exposes_first_level(model, face_id)
|
||||
info = model.face_info(face_id)
|
||||
current_center = _center(info)
|
||||
target_center = (current_center[0], current_center[1], current_center[2] + 2.0)
|
||||
|
||||
plans = (
|
||||
("center local", model.face_center_local_move_plan(face_id, target_center)),
|
||||
("area local", model.face_area_local_resize_plan(face_id, 144.0)),
|
||||
("width local", model.face_size_local_resize_plan(face_id, 15.0, "width")),
|
||||
("offset local", model.face_plane_offset_local_plan(face_id, 2.0)),
|
||||
("push pull", model.push_pull_plan(face_id, 2.0)),
|
||||
)
|
||||
for label, plan in plans:
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label} unexpectedly blocked: {plan}")
|
||||
_assert_plan_exposes_first_level(plan, label)
|
||||
|
||||
before = model.stats()
|
||||
result = model.move_face_center_local(face_id, target_center)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"Local Face move changed solid count: before={before}, after={after}")
|
||||
if after.faces != before.faces:
|
||||
raise SystemExit(f"Cube first-level local move should keep face count stable: before={before}, after={after}")
|
||||
|
||||
z_values = _plane_center_z_values(model)
|
||||
if len(z_values) != 6:
|
||||
raise SystemExit(f"Expected 6 planar Faces after local move, got z values {z_values}")
|
||||
_assert_close(z_values[0], 0.0, "Second-level bottom Face center Z")
|
||||
for index, value in enumerate(z_values[1:5], start=1):
|
||||
_assert_close(value, 6.0, f"First-level side Face {index} center Z")
|
||||
_assert_close(z_values[-1], 12.0, "Moved source Face center Z")
|
||||
|
||||
print(f"model={DEFAULT_MODEL}")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"topology={topology}")
|
||||
print(f"z_values_after_local_move={z_values}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -130,6 +130,17 @@ def main() -> int:
|
||||
if not bool(push_pull_mode.get("enabled", False)):
|
||||
raise SystemExit("planar cylinder cap push/pull scope should remain available")
|
||||
|
||||
huge_push_plan = model.push_pull_plan(face_id, 50.0)
|
||||
if huge_push_plan.get("status") != "blocked":
|
||||
raise SystemExit(f"huge planar cylinder cap push/pull should be blocked early: {huge_push_plan}")
|
||||
old_height = float(huge_push_plan.get("cylindrical_cap_extension_old_height") or 0.0)
|
||||
new_height = float(huge_push_plan.get("cylindrical_cap_extension_new_height") or 0.0)
|
||||
if old_height <= 0.0 or new_height <= old_height * 3.0:
|
||||
raise SystemExit(f"huge cap push/pull should expose the blocked height growth: {huge_push_plan}")
|
||||
huge_message = str(huge_push_plan.get("message") or "")
|
||||
if "圆柱高度" not in huge_message and "cylindrical" not in huge_message.lower():
|
||||
raise SystemExit(f"huge cap push/pull should explain the cylinder-height risk: {huge_push_plan}")
|
||||
|
||||
before_height = _bbox_height(model)
|
||||
push_plan = model.push_pull_plan(face_id, 1.0)
|
||||
if push_plan.get("status") == "blocked":
|
||||
|
||||
@@ -281,6 +281,8 @@ def main() -> int:
|
||||
resolved_strategy = "push-pull-planar-face"
|
||||
if resolved_strategy != expected_strategy:
|
||||
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
|
||||
if "Face result check:" not in result:
|
||||
raise SystemExit(f"Face edit result message should include a result check, got: {result}")
|
||||
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
|
||||
|
||||
|
||||
def assert_keys(info: dict[str, object], expected: tuple[str, ...]) -> None:
|
||||
actual = _feature_dimension_keys(info)
|
||||
if actual != expected:
|
||||
raise AssertionError(f"expected {expected}, got {actual} for {info}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "hole/groove candidate",
|
||||
"angular_span": math.tau,
|
||||
},
|
||||
("diameter", "hole_depth_estimate"),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "hole/groove candidate",
|
||||
"angular_span": math.pi,
|
||||
},
|
||||
(
|
||||
"slot_chord_width_estimate",
|
||||
"slot_sagitta_depth_estimate",
|
||||
"slot_total_length_estimate",
|
||||
),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "boss/outer-round candidate",
|
||||
"angular_span": math.tau,
|
||||
},
|
||||
("boss_diameter", "boss_height"),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "round/fillet candidate",
|
||||
"angular_span": math.pi / 2.0,
|
||||
},
|
||||
("existing_fillet_radius_estimate",),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane", "shell_region_status": "candidate"},
|
||||
("shell_thickness_estimate",),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
"surface": "plane",
|
||||
"prismatic_profile_status": "candidate",
|
||||
"prismatic_extrusion_status": "candidate",
|
||||
},
|
||||
("local_face_width", "local_face_height", "shell_thickness_estimate"),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "torus"},
|
||||
("torus_major_radius", "torus_minor_radius"),
|
||||
)
|
||||
|
||||
specs = [
|
||||
{
|
||||
"key": "area",
|
||||
"editable": True,
|
||||
"enabled": True,
|
||||
"label": "area",
|
||||
},
|
||||
{
|
||||
"key": "diameter",
|
||||
"editable": True,
|
||||
"enabled": True,
|
||||
"label": "diameter",
|
||||
},
|
||||
{
|
||||
"key": "hole_depth_estimate",
|
||||
"editable": True,
|
||||
"enabled": False,
|
||||
"label": "depth",
|
||||
},
|
||||
{
|
||||
"key": "hole_edit_semantics",
|
||||
"editable": False,
|
||||
"enabled": False,
|
||||
"label": "semantics",
|
||||
},
|
||||
]
|
||||
filtered = WindowStateMixin._feature_property_specs(
|
||||
object(),
|
||||
specs,
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "hole/groove candidate",
|
||||
"angular_span": math.tau,
|
||||
},
|
||||
)
|
||||
filtered_keys = tuple(str(spec.get("key")) for spec in filtered)
|
||||
if filtered_keys != ("diameter", "hole_edit_semantics"):
|
||||
raise AssertionError(f"unexpected filtered feature parameters: {filtered_keys}")
|
||||
|
||||
print("feature parameter policy ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -73,6 +73,46 @@ def _run_worker_case(
|
||||
return model
|
||||
|
||||
|
||||
def _run_main_worker_entry_case() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_main_worker_entry_") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
output_path = temp_root / "output.step"
|
||||
request_path = temp_root / "request.json"
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(DEFAULT_MODEL),
|
||||
"output_path": str(output_path),
|
||||
"operation": "resize_face_area_local",
|
||||
"args": [0, 144.0],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "main.py", "--isolated-edit-worker", str(request_path)],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
response_path = request_path.with_suffix(".response.json")
|
||||
if completed.returncode != 0:
|
||||
detail = response_path.read_text(encoding="utf-8") if response_path.exists() else completed.stderr
|
||||
raise SystemExit(f"main worker entry failed with code {completed.returncode}: {detail}")
|
||||
response = json.loads(response_path.read_text(encoding="utf-8"))
|
||||
if not response.get("ok") or not output_path.exists():
|
||||
raise SystemExit(f"main worker entry did not produce an edited STEP: {response}")
|
||||
model = StepModel.load(output_path)
|
||||
_assert_face_area("main worker entry", model, 144.0)
|
||||
print("main worker entry ok")
|
||||
|
||||
|
||||
def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
|
||||
try:
|
||||
return abs(float(value) - target) <= tolerance
|
||||
@@ -80,6 +120,15 @@ def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _logical_region_has_width(model: StepModel, logical_id: int, target_width: float) -> bool:
|
||||
for face_id in model.face_ids_for_logical_id(logical_id):
|
||||
info = model.face_info(face_id)
|
||||
width = info.get("local_face_width")
|
||||
if _float_close(width, target_width, tolerance=1e-4):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _triple_close(value: object, target: tuple[float, float, float], tolerance: float = 1e-5) -> bool:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return False
|
||||
@@ -150,6 +199,21 @@ def _write_shell_plate(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), path)
|
||||
|
||||
|
||||
def _write_sphere_model(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), path)
|
||||
|
||||
|
||||
def _write_torus_model(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeTorus(8.0, 2.0).Shape(), path)
|
||||
|
||||
|
||||
def _first_face_by_surface(model: StepModel, surface: str) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == surface:
|
||||
return face_id
|
||||
raise SystemExit(f"no {surface} Face was recognized")
|
||||
|
||||
|
||||
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
@@ -177,6 +241,35 @@ def _assert_shell_thickness(label: str, model: StepModel, target_thickness: floa
|
||||
print(f"matched_shell_thickness={thickness:g}, bbox_size={size}")
|
||||
|
||||
|
||||
def _assert_sphere_radius(label: str, model: StepModel, target_radius: float = 10.0) -> None:
|
||||
matches = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "sphere":
|
||||
continue
|
||||
radius = info.get("radius")
|
||||
if _float_close(radius, target_radius, tolerance=1e-4):
|
||||
matches.append((face_id, float(radius)))
|
||||
if not matches:
|
||||
raise SystemExit(f"{label}: output does not contain a sphere Face radius {target_radius:g}")
|
||||
print(f"matched_sphere_radii={matches}")
|
||||
|
||||
|
||||
def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float = 4.0) -> None:
|
||||
matches = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "torus":
|
||||
continue
|
||||
major = info.get("major_radius")
|
||||
minor = info.get("minor_radius")
|
||||
if _float_close(minor, target_minor, tolerance=1e-4):
|
||||
matches.append((face_id, float(major), float(minor)))
|
||||
if not matches:
|
||||
raise SystemExit(f"{label}: output does not contain a torus Face minor radius {target_minor:g}")
|
||||
print(f"matched_torus_radii={matches}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_run_worker_case(
|
||||
label="面偏移(当前面)",
|
||||
@@ -252,6 +345,39 @@ def main() -> int:
|
||||
input_path=shell_path,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_curved_face_verify_") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
|
||||
sphere_path = temp_root / "sphere.step"
|
||||
_write_sphere_model(sphere_path)
|
||||
sphere_probe = StepModel.load(sphere_path)
|
||||
sphere_face_id = _first_face_by_surface(sphere_probe, "sphere")
|
||||
sphere_plan = sphere_probe.spherical_radius_plan(sphere_face_id, 10.0)
|
||||
if str(sphere_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"sphere radius isolation case should be high risk, got {sphere_plan}")
|
||||
_run_worker_case(
|
||||
label="sphere radius",
|
||||
operation="resize_sphere_radius",
|
||||
args=[sphere_face_id, 10.0],
|
||||
validator=_assert_sphere_radius,
|
||||
input_path=sphere_path,
|
||||
)
|
||||
|
||||
torus_path = temp_root / "torus.step"
|
||||
_write_torus_model(torus_path)
|
||||
torus_probe = StepModel.load(torus_path)
|
||||
torus_face_id = _first_face_by_surface(torus_probe, "torus")
|
||||
torus_plan = torus_probe.toroidal_radius_plan(torus_face_id, 4.0, "minor")
|
||||
if str(torus_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"torus minor-radius isolation case should be high risk, got {torus_plan}")
|
||||
_run_worker_case(
|
||||
label="torus minor radius",
|
||||
operation="resize_torus_radius",
|
||||
args=[torus_face_id, 4.0, "minor"],
|
||||
validator=_assert_torus_minor_radius,
|
||||
input_path=torus_path,
|
||||
)
|
||||
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
|
||||
class _IsolatedJobProbe(WindowActionMixin):
|
||||
@@ -260,6 +386,29 @@ def main() -> int:
|
||||
self.step_path = DEFAULT_MODEL
|
||||
|
||||
probe = _IsolatedJobProbe()
|
||||
if probe._isolated_edit_command(Path("request.json")) != [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"step_editor.isolated_edit_worker",
|
||||
"request.json",
|
||||
]:
|
||||
raise SystemExit("source isolation command should use python -m step_editor.isolated_edit_worker")
|
||||
had_frozen_attr = hasattr(sys, "frozen")
|
||||
previous_frozen = getattr(sys, "frozen", None)
|
||||
try:
|
||||
setattr(sys, "frozen", True)
|
||||
if probe._isolated_edit_command(Path("request.json")) != [
|
||||
sys.executable,
|
||||
"--isolated-edit-worker",
|
||||
"request.json",
|
||||
]:
|
||||
raise SystemExit("frozen isolation command should call main.exe --isolated-edit-worker")
|
||||
finally:
|
||||
if had_frozen_attr:
|
||||
setattr(sys, "frozen", previous_frozen)
|
||||
else:
|
||||
delattr(sys, "frozen")
|
||||
|
||||
for title in (
|
||||
"面宽(当前面)",
|
||||
"面高(当前面)",
|
||||
@@ -291,12 +440,15 @@ def main() -> int:
|
||||
snapshot=snapshot,
|
||||
before_stats=probe.model.stats(),
|
||||
before_part_stats=probe.model.part_topology_stats(1),
|
||||
before_quality=probe._edit_quality_info_or_none(probe.model, context, 1),
|
||||
before_geometry={},
|
||||
)
|
||||
if "隔离子进程" not in str(result.get("message", "")):
|
||||
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
|
||||
if probe.model.stats().solids != 1:
|
||||
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
|
||||
if not _logical_region_has_width(probe.model, 0, 25.0):
|
||||
raise SystemExit("isolated window job did not preserve the original logical Face ID on the edited width")
|
||||
print("isolated window job ok")
|
||||
|
||||
owning_probe = _IsolatedJobProbe()
|
||||
@@ -317,12 +469,16 @@ def main() -> int:
|
||||
snapshot=owning_probe.model.snapshot(),
|
||||
before_stats=owning_probe.model.stats(),
|
||||
before_part_stats=owning_probe.model.part_topology_stats(1),
|
||||
before_quality=owning_probe._edit_quality_info_or_none(owning_probe.model, context, 1),
|
||||
before_geometry={},
|
||||
)
|
||||
if "隔离子进程" not in str(owning_result.get("message", "")):
|
||||
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
|
||||
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
|
||||
if not _logical_region_has_width(owning_probe.model, 0, 25.0):
|
||||
raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
|
||||
print("isolated owning window job ok")
|
||||
_run_main_worker_entry_case()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCC.Core.gp import gp_Pnt
|
||||
|
||||
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.model import StepModel
|
||||
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
||||
|
||||
|
||||
def close_to(value: object, expected: float, tolerance: float = 1e-6) -> bool:
|
||||
try:
|
||||
return abs(float(value) - expected) <= tolerance
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def find_feature(model: StepModel, feature_type: str) -> dict[str, object]:
|
||||
matches = [model.feature_info(face_id) for face_id in range(len(model.faces))]
|
||||
matches = [info for info in matches if info.get("feature_type") == feature_type]
|
||||
if not matches:
|
||||
observed = sorted({str(model.feature_info(face_id).get("feature_type")) for face_id in range(len(model.faces))})
|
||||
raise AssertionError(f"expected {feature_type}, observed {observed}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def assert_prismatic_sizes(
|
||||
info: dict[str, object], length: float, width: float, depth: float
|
||||
) -> None:
|
||||
expected = {
|
||||
"prismatic_length": length,
|
||||
"prismatic_width": width,
|
||||
"prismatic_extrusion_estimate": depth,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if not close_to(info.get(key), value):
|
||||
raise AssertionError(f"{key}={info.get(key)!r}, expected {value}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
model = StepModel.load(DEFAULT_MODEL)
|
||||
if len(model.faces) != 6:
|
||||
raise AssertionError(f"expected 6 cube faces, got {len(model.faces)}")
|
||||
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.feature_info(face_id)
|
||||
if info.get("prismatic_profile_status") != "candidate":
|
||||
raise AssertionError(f"Face {face_id} was not recognized as a rectangular profile")
|
||||
if info.get("prismatic_extrusion_status") != "candidate":
|
||||
raise AssertionError(f"Face {face_id} was not recognized as a connected extrusion")
|
||||
for key in ("prismatic_length", "prismatic_width", "prismatic_extrusion_estimate"):
|
||||
if not close_to(info.get(key), 10.0):
|
||||
raise AssertionError(f"Face {face_id} {key}={info.get(key)!r}, expected 10")
|
||||
if len(tuple(info.get("prismatic_connected_side_face_ids", ()))) != 4:
|
||||
raise AssertionError(f"Face {face_id} does not have four connected side faces")
|
||||
|
||||
info = model.feature_info(0)
|
||||
expected_keys = ("local_face_width", "local_face_height", "shell_thickness_estimate")
|
||||
if _feature_dimension_keys(info) != expected_keys:
|
||||
raise AssertionError(f"unexpected cube feature dimensions: {_feature_dimension_keys(info)}")
|
||||
|
||||
state = object.__new__(WindowStateMixin)
|
||||
state.model = model
|
||||
state.operation_in_progress = False
|
||||
state.scan_in_progress = False
|
||||
state.load_in_progress = False
|
||||
state.selected_face_id = 0
|
||||
state.selected_edge_id = None
|
||||
state.selected_kind = "feature"
|
||||
state.selected_part_id = int(info["part_id"])
|
||||
state.selected_solid_id = int(info["solid_id"])
|
||||
specs, _used = state._editable_property_specs(info)
|
||||
filtered = state._feature_property_specs(specs, info)
|
||||
dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"]
|
||||
actual = tuple(
|
||||
(
|
||||
str(spec.get("key")),
|
||||
str(spec.get("label")),
|
||||
str(spec.get("current_text")),
|
||||
)
|
||||
for spec in dimensions
|
||||
)
|
||||
expected = (
|
||||
("local_face_width", "长度", "10"),
|
||||
("local_face_height", "宽度", "10"),
|
||||
("shell_thickness_estimate", "高度/深度", "10"),
|
||||
)
|
||||
if actual != expected:
|
||||
raise AssertionError(f"unexpected prismatic UI parameters: {actual}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="step-editor-prismatic-") as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
|
||||
|
||||
pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape()
|
||||
pocket_path = temp_path / "rectangular-pocket.step"
|
||||
_write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), pocket_path)
|
||||
pocket_info = find_feature(StepModel.load(pocket_path), "矩形口袋候选")
|
||||
assert_prismatic_sizes(pocket_info, 10.0, 8.0, 3.0)
|
||||
if pocket_info.get("prismatic_reference_source") != "side-wall-topology":
|
||||
raise AssertionError(f"unexpected pocket reference: {pocket_info.get('prismatic_reference_source')}")
|
||||
|
||||
boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape()
|
||||
boss_path = temp_path / "rectangular-boss.step"
|
||||
_write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), boss_path)
|
||||
boss_info = find_feature(StepModel.load(boss_path), "矩形凸台候选")
|
||||
assert_prismatic_sizes(boss_info, 10.0, 8.0, 3.0)
|
||||
if boss_info.get("prismatic_reference_source") not in {
|
||||
"side-wall-topology", "overlapping-plane"
|
||||
}:
|
||||
raise AssertionError(f"unexpected boss reference: {boss_info.get('prismatic_reference_source')}")
|
||||
|
||||
print("prismatic feature recognition ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -36,6 +36,13 @@ def _specs(info: dict[str, object]) -> list[dict[str, object]]:
|
||||
return specs
|
||||
|
||||
|
||||
def _display_specs(info: dict[str, object]) -> list[dict[str, object]]:
|
||||
probe = _PropertySpecProbe()
|
||||
probe.selected_kind = "face"
|
||||
specs = probe._property_editor_specs(info, info)
|
||||
return probe._sort_property_specs_for_display(specs)
|
||||
|
||||
|
||||
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
|
||||
for spec in specs:
|
||||
if spec.get("key") != key:
|
||||
@@ -145,6 +152,33 @@ def _assert_no_generic_face_leak(keys: set[str], label: str) -> None:
|
||||
raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}")
|
||||
|
||||
|
||||
def _is_actionable_edit_spec(spec: dict[str, object]) -> bool:
|
||||
return (
|
||||
bool(spec.get("editable"))
|
||||
and bool(spec.get("enabled"))
|
||||
and bool(spec.get("action"))
|
||||
and str(spec.get("value_type", "number")) != "command"
|
||||
)
|
||||
|
||||
|
||||
def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[str, ...], label: str) -> None:
|
||||
display_specs = _display_specs({**info, "readonly_probe_for_ordering": "readonly"})
|
||||
seen_non_actionable = False
|
||||
front_keys: list[str] = []
|
||||
for spec in display_specs:
|
||||
key = str(spec.get("key", ""))
|
||||
if _is_actionable_edit_spec(spec):
|
||||
if seen_non_actionable:
|
||||
raise SystemExit(f"{label}: actionable row {key!r} appeared after a read-only/non-action row")
|
||||
front_keys.append(key)
|
||||
else:
|
||||
seen_non_actionable = True
|
||||
|
||||
missing = [key for key in expected_keys if key not in front_keys]
|
||||
if missing:
|
||||
raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}")
|
||||
|
||||
|
||||
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
|
||||
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
|
||||
hits: list[str] = []
|
||||
@@ -292,6 +326,14 @@ def main() -> int:
|
||||
"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),
|
||||
"topology_relation_depth": 1,
|
||||
"topology_relation_status": "ready",
|
||||
"same_domain_face_count": 1,
|
||||
"first_level_boundary_edge_count": 4,
|
||||
"first_level_boundary_vertex_count": 4,
|
||||
"first_level_adjacent_face_count": 4,
|
||||
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
|
||||
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
||||
}
|
||||
plane_specs = _specs(plane_info)
|
||||
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
|
||||
@@ -309,7 +351,35 @@ def main() -> int:
|
||||
_assert_label(plane_specs, "local_face_width", "面宽")
|
||||
_assert_label(plane_specs, "local_face_height", "面高")
|
||||
_assert_label(plane_specs, "face_target_normal_position", "面偏移")
|
||||
_assert_actionable_rows_first(
|
||||
plane_info,
|
||||
(
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
),
|
||||
"plane Face display order",
|
||||
)
|
||||
_assert_no_legacy_face_terms(plane_specs)
|
||||
plane_feature_probe = _PropertySpecProbe()
|
||||
plane_feature_probe.selected_kind = "feature"
|
||||
plane_feature_specs, _used = plane_feature_probe._editable_property_specs(plane_info)
|
||||
plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info)
|
||||
plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows}
|
||||
topology_spec = _spec(plane_feature_rows, "face_first_level_topology")
|
||||
topology_text = str(topology_spec.get("current_text") or "")
|
||||
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
|
||||
if fragment not in topology_text:
|
||||
raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}")
|
||||
if "face_target_normal_position" not in plane_feature_keys:
|
||||
raise SystemExit(
|
||||
"plane feature mode should expose the current Face offset parameter; "
|
||||
f"got {sorted(plane_feature_keys)}"
|
||||
)
|
||||
if "no_editable_feature_dimensions" in plane_feature_keys:
|
||||
raise SystemExit("plane feature mode should not fall back to no editable dimensions")
|
||||
_assert_hard_range(plane_specs, "area", 0.25, 2500.0)
|
||||
_assert_hard_range(plane_specs, "local_face_width", 0.5, 50.0)
|
||||
_assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0)
|
||||
@@ -526,9 +596,18 @@ def main() -> int:
|
||||
),
|
||||
)
|
||||
for label, info, required in analytic_cases:
|
||||
keys = _spec_keys(info)
|
||||
specs = _specs(info)
|
||||
keys = {str(spec.get("key", "")) for spec in specs}
|
||||
_assert_no_generic_face_leak(keys, label)
|
||||
_assert_contains(keys, required, label)
|
||||
if label == "cone feature":
|
||||
_assert_label(specs, "cone_reference_radius", "参考半径")
|
||||
_assert_label(specs, "cone_reference_diameter", "参考直径")
|
||||
_assert_label(specs, "cone_semi_angle_degrees", "圆锥半角")
|
||||
display_specs = _display_specs(info)
|
||||
surface_spec = _spec(display_specs, "surface")
|
||||
if surface_spec.get("current_text") != "圆锥面 / 拔模面":
|
||||
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}")
|
||||
|
||||
_assert_target_change_detection()
|
||||
_assert_holed_plane_local_scopes_disabled()
|
||||
|
||||
Reference in New Issue
Block a user