399 lines
18 KiB
Python
399 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
|
|
from OCC.Core.BRepAlgoAPI import 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.step_io import _write_step
|
|
|
|
|
|
def _write_plate_model(path: Path) -> None:
|
|
shape = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
|
|
_write_step(shape, path)
|
|
|
|
|
|
def _fuse_shapes(left, right):
|
|
op = BRepAlgoAPI_Fuse(left, right)
|
|
op.SetFuzzyValue(1e-6)
|
|
op.Build()
|
|
if not op.IsDone():
|
|
raise RuntimeError("open shell fixture fuse failed")
|
|
return op.Shape()
|
|
|
|
|
|
def _write_open_thin_wall_box_model(path: Path) -> None:
|
|
bottom = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
|
|
walls = (
|
|
BRepPrimAPI_MakeBox(gp_Pnt(0.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
|
|
BRepPrimAPI_MakeBox(gp_Pnt(28.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
|
|
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 0.0, 2.0), 26.0, 2.0, 10.0).Shape(),
|
|
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 18.0, 2.0), 26.0, 2.0, 10.0).Shape(),
|
|
)
|
|
shape = bottom
|
|
for wall in walls:
|
|
shape = _fuse_shapes(shape, wall)
|
|
_write_step(shape, path)
|
|
|
|
|
|
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
|
|
candidates: list[tuple[int, int]] = []
|
|
for face_id in range(len(model.faces)):
|
|
info = model.feature_info(face_id)
|
|
if info.get("surface") != "plane":
|
|
continue
|
|
if info.get("shell_region_status") != "candidate":
|
|
continue
|
|
current = float(info.get("shell_thickness_estimate") or 0.0)
|
|
if abs(current - thickness) > tolerance:
|
|
continue
|
|
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
|
|
candidates.append((confidence_rank, face_id))
|
|
if not candidates:
|
|
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
|
|
candidates.sort()
|
|
return candidates[0][1]
|
|
|
|
|
|
def _bounds(model: StepModel) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]:
|
|
info = model.geometry_stats()
|
|
return (
|
|
tuple(float(value) for value in info["bbox_min"]),
|
|
tuple(float(value) for value in info["bbox_max"]),
|
|
tuple(float(value) for value in info["bbox_size"]),
|
|
)
|
|
|
|
|
|
def _thickness_axis(size: tuple[float, float, float]) -> int:
|
|
return min(range(3), key=lambda index: size[index])
|
|
|
|
|
|
def _face_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
|
|
info = model.face_info(face_id)
|
|
center = info.get("area_center") or info.get("bbox_center")
|
|
if not isinstance(center, tuple) or len(center) != 3:
|
|
raise SystemExit(f"Face {face_id} lacks a stable center")
|
|
return float(center[0]), float(center[1]), float(center[2])
|
|
|
|
|
|
def _face_area(model: StepModel, face_id: int) -> float:
|
|
return float(model.face_info(face_id).get("area") or 0.0)
|
|
|
|
|
|
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
|
|
|
|
|
|
def _vector(value: object, label: str) -> tuple[float, float, float]:
|
|
if not isinstance(value, tuple) or len(value) != 3:
|
|
raise SystemExit(f"{label} should be a 3D vector, got {value!r}")
|
|
return float(value[0]), float(value[1]), float(value[2])
|
|
|
|
|
|
def _axis_affine_point(
|
|
point: tuple[float, float, float],
|
|
axis_point: tuple[float, float, float],
|
|
axis_direction: tuple[float, float, float],
|
|
scale: float,
|
|
) -> tuple[float, float, float]:
|
|
length = (axis_direction[0] ** 2 + axis_direction[1] ** 2 + axis_direction[2] ** 2) ** 0.5
|
|
if length <= 1e-12:
|
|
raise SystemExit("owning shell thickness plan produced a zero axis direction")
|
|
direction = (axis_direction[0] / length, axis_direction[1] / length, axis_direction[2] / length)
|
|
relative = (point[0] - axis_point[0], point[1] - axis_point[1], point[2] - axis_point[2])
|
|
along = relative[0] * direction[0] + relative[1] * direction[1] + relative[2] * direction[2]
|
|
axial = (direction[0] * along, direction[1] * along, direction[2] * along)
|
|
rest = (relative[0] - axial[0], relative[1] - axial[1], relative[2] - axial[2])
|
|
return (
|
|
axis_point[0] + rest[0] + axial[0] * scale,
|
|
axis_point[1] + rest[1] + axial[1] * scale,
|
|
axis_point[2] + rest[2] + axial[2] * scale,
|
|
)
|
|
|
|
|
|
def _assert_shell_plan_topology(plan: dict[str, object], label: str) -> None:
|
|
if plan.get("topology_relation_depth") != 1:
|
|
raise SystemExit(f"{label} should expose first-level topology depth: {plan}")
|
|
if plan.get("topology_relation_status") != "ready":
|
|
raise SystemExit(f"{label} first-level topology should be ready: {plan}")
|
|
if int(plan.get("first_level_boundary_edge_count", 0) or 0) < 1:
|
|
raise SystemExit(f"{label} should expose boundary Edges: {plan}")
|
|
if int(plan.get("first_level_boundary_vertex_count", 0) or 0) < 1:
|
|
raise SystemExit(f"{label} should expose boundary Vertices: {plan}")
|
|
if int(plan.get("first_level_adjacent_face_count", 0) or 0) < 1:
|
|
raise SystemExit(f"{label} should expose direct adjacent Faces: {plan}")
|
|
ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ())
|
|
if "second-level" not in ignored or "third-level" not in ignored:
|
|
raise SystemExit(f"{label} should document ignored deeper topology: {plan}")
|
|
if plan.get("first_level_fact_status") != "ready":
|
|
raise SystemExit(f"{label} should expose a ready first-level fact graph: {plan}")
|
|
|
|
|
|
def _assert_logical_face_retained(
|
|
model: StepModel,
|
|
logical_id: int,
|
|
expected_center: tuple[float, float, float],
|
|
expected_area: float,
|
|
tolerance: float,
|
|
label: str,
|
|
) -> int:
|
|
matches = model.face_ids_for_logical_id(logical_id)
|
|
if not matches:
|
|
raise SystemExit(f"{label} did not retain logical Face {logical_id}")
|
|
resolved = model.resolve_face_selection_id(logical_id)
|
|
if resolved is None or resolved not in matches:
|
|
raise SystemExit(f"{label} logical Face {logical_id} did not resolve into matches {matches}")
|
|
info = model.face_info(resolved)
|
|
if info.get("surface") != "plane":
|
|
raise SystemExit(f"{label} retained logical Face should be planar, got {info.get('surface')}")
|
|
center = _face_center(model, resolved)
|
|
if _distance(center, expected_center) > tolerance:
|
|
raise SystemExit(f"{label} retained Face center should be {expected_center}, got {center}")
|
|
area = _face_area(model, resolved)
|
|
if abs(area - expected_area) > tolerance:
|
|
raise SystemExit(f"{label} retained Face area should be {expected_area:g}, got {area:g}")
|
|
return resolved
|
|
|
|
|
|
def _verify_local_bounds(
|
|
before_min: tuple[float, float, float],
|
|
before_max: tuple[float, float, float],
|
|
after_min: tuple[float, float, float],
|
|
after_max: tuple[float, float, float],
|
|
axis: int,
|
|
target: float,
|
|
tolerance: float,
|
|
) -> None:
|
|
after_size = after_max[axis] - after_min[axis]
|
|
if abs(after_size - target) > tolerance:
|
|
raise SystemExit(f"local shell thickness failed: target={target:g}, value={after_size:g}")
|
|
min_unchanged = abs(after_min[axis] - before_min[axis]) <= tolerance
|
|
max_unchanged = abs(after_max[axis] - before_max[axis]) <= tolerance
|
|
if not (min_unchanged or max_unchanged):
|
|
raise SystemExit(
|
|
"local shell thickness did not keep either opposite side fixed: "
|
|
f"before=({before_min[axis]:g}, {before_max[axis]:g}), "
|
|
f"after=({after_min[axis]:g}, {after_max[axis]:g})"
|
|
)
|
|
|
|
|
|
def _verify_owning_bounds(
|
|
before_min: tuple[float, float, float],
|
|
before_max: tuple[float, float, float],
|
|
after_min: tuple[float, float, float],
|
|
after_max: tuple[float, float, float],
|
|
axis: int,
|
|
target: float,
|
|
tolerance: float,
|
|
) -> None:
|
|
after_size = after_max[axis] - after_min[axis]
|
|
if abs(after_size - target) > tolerance:
|
|
raise SystemExit(f"owning shell thickness failed: target={target:g}, value={after_size:g}")
|
|
before_center = (before_min[axis] + before_max[axis]) * 0.5
|
|
after_center = (after_min[axis] + after_max[axis]) * 0.5
|
|
if abs(after_center - before_center) > tolerance:
|
|
raise SystemExit(
|
|
"owning shell thickness did not keep the thickness center fixed: "
|
|
f"before_center={before_center:g}, after_center={after_center:g}"
|
|
)
|
|
|
|
|
|
def _run_case(mode: str, source_thickness: float, target_thickness: float, tolerance: float) -> None:
|
|
with tempfile.TemporaryDirectory(prefix=f"geom_param_shell_{mode}_") as temp_dir:
|
|
model_path = Path(temp_dir) / "plate.step"
|
|
_write_plate_model(model_path)
|
|
model = StepModel.load(model_path)
|
|
face_id = _first_shell_face(model, source_thickness, tolerance)
|
|
logical_id = model.face_region_logical_id(face_id)
|
|
before_face_center = _face_center(model, face_id)
|
|
before_face_area = _face_area(model, face_id)
|
|
before = model.stats()
|
|
before_min, before_max, before_size = _bounds(model)
|
|
axis = _thickness_axis(before_size)
|
|
|
|
if mode == "local":
|
|
plan = model.shell_thickness_plan(face_id, target_thickness)
|
|
if plan["status"] == "blocked":
|
|
raise SystemExit(f"local shell plan was blocked: {plan['message']}")
|
|
_assert_shell_plan_topology(plan, "local shell thickness plan")
|
|
outward = _vector(plan.get("outward_direction"), "local shell outward_direction")
|
|
distance = float(plan.get("push_pull_distance", 0.0))
|
|
expected_logical_center = (
|
|
before_face_center[0] + outward[0] * distance,
|
|
before_face_center[1] + outward[1] * distance,
|
|
before_face_center[2] + outward[2] * distance,
|
|
)
|
|
result = model.resize_shell_thickness(face_id, target_thickness)
|
|
elif mode == "owning":
|
|
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
|
|
if plan["status"] == "blocked":
|
|
raise SystemExit(f"owning shell plan was blocked: {plan['message']}")
|
|
_assert_shell_plan_topology(plan, "owning shell thickness plan")
|
|
expected_logical_center = _axis_affine_point(
|
|
before_face_center,
|
|
_vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"),
|
|
_vector(plan.get("affine_axis_direction"), "owning shell affine_axis_direction"),
|
|
float(plan.get("affine_scale", 1.0)),
|
|
)
|
|
result = model.resize_shell_thickness_owning_scale(face_id, target_thickness)
|
|
else:
|
|
raise SystemExit(f"unsupported mode: {mode}")
|
|
|
|
after = model.stats()
|
|
after_min, after_max, after_size = _bounds(model)
|
|
if after.solids != before.solids:
|
|
raise SystemExit(f"{mode} shell thickness changed solid count: before={before.solids}, after={after.solids}")
|
|
if mode == "local":
|
|
_verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
|
|
else:
|
|
_verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
|
|
retained_face_id = _assert_logical_face_retained(
|
|
model,
|
|
logical_id,
|
|
expected_logical_center,
|
|
before_face_area,
|
|
tolerance,
|
|
f"{mode} shell thickness",
|
|
)
|
|
if "Face result check" not in result:
|
|
raise SystemExit(f"{mode} shell thickness result did not report Face result check: {result}")
|
|
if "First-level check" not in result:
|
|
raise SystemExit(f"{mode} shell thickness result did not report first-level check: {result}")
|
|
|
|
print(f"mode={mode}")
|
|
print(f"face_id={face_id}")
|
|
print(f"logical_face_id={logical_id}")
|
|
print(f"retained_logical_face={retained_face_id}")
|
|
print(f"strategy={plan.get('resize_strategy')}")
|
|
print(f"before={before}")
|
|
print(f"after={after}")
|
|
print(f"before_bbox_size={before_size}")
|
|
print(f"after_bbox_size={after_size}")
|
|
print(f"axis={axis}")
|
|
print(f"source_thickness={source_thickness:.6f} target_thickness={target_thickness:.6f}")
|
|
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
|
|
|
|
|
def _first_open_shell_wall_face(model: StepModel, thickness: float, tolerance: float) -> int:
|
|
open_shell_faces: list[int] = []
|
|
candidates: list[tuple[int, int]] = []
|
|
for face_id in range(len(model.faces)):
|
|
info = model.feature_info(face_id)
|
|
if info.get("open_shell_context_status") == "limited":
|
|
open_shell_faces.append(face_id)
|
|
if info.get("surface") != "plane":
|
|
continue
|
|
if info.get("shell_region_status") != "candidate":
|
|
continue
|
|
if info.get("open_shell_context_status") != "limited":
|
|
continue
|
|
current = float(info.get("shell_thickness_estimate") or 0.0)
|
|
if abs(current - thickness) > tolerance:
|
|
continue
|
|
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
|
|
candidates.append((confidence_rank, face_id))
|
|
if not open_shell_faces:
|
|
raise SystemExit("open thin-wall box should expose an open_shell limited context")
|
|
if not candidates:
|
|
raise SystemExit(f"no open shell wall face with thickness near {thickness:g}")
|
|
candidates.sort()
|
|
return candidates[0][1]
|
|
|
|
|
|
def _assert_no_open_shell_context(model: StepModel, label: str) -> None:
|
|
leaked = [
|
|
face_id
|
|
for face_id in range(len(model.faces))
|
|
if model.feature_info(face_id).get("open_shell_context_status") == "limited"
|
|
]
|
|
if leaked:
|
|
raise SystemExit(f"{label} should not be recognized as open shell context: {leaked}")
|
|
|
|
|
|
def _run_open_shell_context_case(source_thickness: float, target_thickness: float, tolerance: float) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="geom_param_open_shell_context_") as temp_dir:
|
|
root = Path(temp_dir)
|
|
open_path = root / "open_thin_wall_box.step"
|
|
_write_open_thin_wall_box_model(open_path)
|
|
model = StepModel.load(open_path)
|
|
face_id = _first_open_shell_wall_face(model, source_thickness, tolerance)
|
|
info = model.feature_info(face_id)
|
|
limited_actions = str(info.get("recognition_limited_actions") or "")
|
|
if "完整抽壳/开口面编辑" not in limited_actions:
|
|
raise SystemExit(f"open shell should show full shell/opening edit as limited: {info}")
|
|
ready_actions = str(info.get("recognition_ready_actions") or "")
|
|
if "壳体厚度" not in ready_actions:
|
|
raise SystemExit(f"open shell wall should keep local thickness editable: {info}")
|
|
evidence_keys = tuple(info.get("recognition_evidence_keys") or ())
|
|
if "open_shell_context" not in evidence_keys:
|
|
raise SystemExit(f"open shell recognition evidence should be explicit: {info}")
|
|
|
|
local_plan = model.shell_thickness_plan(face_id, target_thickness)
|
|
if local_plan.get("status") == "blocked":
|
|
raise SystemExit(f"open shell local thickness should remain available: {local_plan}")
|
|
if local_plan.get("open_shell_context_status") != "limited":
|
|
raise SystemExit(f"open shell fields should be present in local plan: {local_plan}")
|
|
if "完整抽壳/开口面" not in str(local_plan.get("message") or ""):
|
|
raise SystemExit(f"open shell local plan should explain the limitation: {local_plan}")
|
|
|
|
owning_plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
|
|
if owning_plan.get("status") == "blocked":
|
|
raise SystemExit(f"open shell owning thickness should remain available: {owning_plan}")
|
|
if owning_plan.get("open_shell_context_status") != "limited":
|
|
raise SystemExit(f"open shell fields should be present in owning plan: {owning_plan}")
|
|
|
|
editable_candidates = model.editable_feature_candidates(limit=30, detailed=False)
|
|
shell_entries = [
|
|
item
|
|
for item in editable_candidates
|
|
if item.get("operation_key") == "resize_shell_thickness" and int(item.get("target_id", -1)) == face_id
|
|
]
|
|
if not shell_entries:
|
|
raise SystemExit(f"open shell wall should be visible as editable shell thickness candidate: {editable_candidates}")
|
|
shell_note = str(shell_entries[0].get("note") or "")
|
|
if "完整抽壳/开口面" not in shell_note:
|
|
raise SystemExit(f"open shell candidate should explain full shell limitation: {shell_entries[0]}")
|
|
|
|
closed_path = root / "closed_plate.step"
|
|
_write_plate_model(closed_path)
|
|
closed_model = StepModel.load(closed_path)
|
|
_assert_no_open_shell_context(closed_model, "closed plate")
|
|
|
|
print("open_shell_context=limited")
|
|
print(f"face_id={face_id}")
|
|
print(f"limited_actions={limited_actions}")
|
|
print(f"local_plan_status={local_plan.get('status')}")
|
|
print(f"owning_plan_status={owning_plan.get('status')}")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.")
|
|
parser.add_argument("--mode", default="all", choices=["all", "local", "owning", "open-shell-context"])
|
|
parser.add_argument("--source-thickness", type=float, default=2.0)
|
|
parser.add_argument("--target-thickness", type=float, default=3.0)
|
|
parser.add_argument("--tolerance", type=float, default=2e-4)
|
|
args = parser.parse_args()
|
|
|
|
modes = ["local", "owning"] if args.mode == "all" else [args.mode]
|
|
for mode in modes:
|
|
if mode == "open-shell-context":
|
|
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
|
|
else:
|
|
_run_case(mode, args.source_thickness, args.target_thickness, args.tolerance)
|
|
if args.mode == "all":
|
|
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|