feat: 完善 Edge 一级编辑与 CAD 建模语义

This commit is contained in:
2026-08-05 18:06:15 +08:00
parent ed1faf51d8
commit 57d75541c3
19 changed files with 2365 additions and 367 deletions
+12
View File
@@ -10,6 +10,10 @@ SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge first-level topology and edit-plan facts",
("verify_edge_first_level_topology.py",),
),
(
"Edge length auto strategy resolves to local deformation",
(
@@ -58,6 +62,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Edge start, center and end coordinate edits",
("verify_edge_coordinate_edit.py",),
),
(
"Edge invalid, no-op and oversized target guards",
("verify_edge_target_guards.py",),
),
(
"Edge modeling-intent scopes and plan mapping",
("verify_edge_intent_specs.py",),
),
(
"Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize",
("verify_edge_round_chamfer.py",),
+153
View File
@@ -0,0 +1,153 @@
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 INFO_LABELS
from step_editor.window_state import WindowStateMixin
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
TOLERANCE = 1e-5
class _EdgeProbe(WindowStateMixin):
def __init__(self, model: StepModel) -> None:
self.model = model
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _line_edge_ids_near_length(model: StepModel, length: float, tolerance: float) -> list[int]:
edge_ids: list[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", 0.0)) - length) <= tolerance:
edge_ids.append(edge_id)
return edge_ids
def _edge_under_test(model: StepModel) -> int:
edge_ids = _line_edge_ids_near_length(model, 10.0, TOLERANCE)
if not edge_ids:
raise AssertionError("No 10mm line Edge found in cube model.")
return edge_ids[0]
def _point(value: object) -> tuple[float, float, float]:
if not isinstance(value, (tuple, list)) or len(value) != 3:
raise AssertionError(f"Expected 3D point, got {value!r}")
return float(value[0]), float(value[1]), float(value[2])
def _assert_edge_topology(model: StepModel, edge_id: int) -> dict[str, object]:
topology = model.edge_first_level_topology(edge_id)
_assert(topology.get("topology_relation_depth") == 1, f"bad Edge topology depth: {topology}")
_assert(
topology.get("topology_relation_boundary") == "shared-vertex/shared-face",
f"bad Edge relation boundary: {topology}",
)
_assert(int(topology.get("selected_edge_count", 0) or 0) == 1, f"bad selected Edge count: {topology}")
_assert(int(topology.get("first_level_vertex_count", 0) or 0) == 2, f"bad endpoint count: {topology}")
_assert(
int(topology.get("first_level_adjacent_face_count", 0) or 0) == 2,
f"cube Edge should have 2 incident Faces: {topology}",
)
_assert(
int(topology.get("first_level_adjacent_edge_count", 0) or 0) == 4,
f"cube Edge should have 4 shared-endpoint neighbor Edges: {topology}",
)
_assert(
int(topology.get("first_level_edge_count", 0) or 0) == 5,
f"cube Edge first-level Edge set should contain selected Edge + 4 neighbors: {topology}",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"missing ignored depths: {topology}")
return topology
def _assert_edge_facts(facts: dict[str, object], label: str) -> None:
_assert(facts.get("first_level_fact_model") == "STEP/B-Rep first-level fact graph", f"{label}: bad model")
_assert(facts.get("first_level_fact_source_model") == "edge", f"{label}: bad source")
_assert(facts.get("first_level_fact_status") == "ready", f"{label}: bad status {facts}")
_assert(facts.get("first_level_fact_scope") == "edge", f"{label}: bad scope {facts}")
_assert(facts.get("first_level_fact_relation_depth") == 1, f"{label}: bad depth {facts}")
_assert(
facts.get("first_level_fact_relation_boundary") == "shared-vertex/shared-face",
f"{label}: bad boundary {facts}",
)
_assert(int(facts.get("first_level_fact_subject_edge_count", 0) or 0) == 1, f"{label}: bad subject")
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 2, f"{label}: bad vertices")
_assert(int(facts.get("first_level_fact_adjacent_edge_count", 0) or 0) == 4, f"{label}: bad adjacent Edges")
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 2, f"{label}: bad adjacent Faces")
_assert(int(facts.get("first_level_fact_included_edge_count", 0) or 0) == 5, f"{label}: bad included Edges")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: missing summary")
def _assert_plan_facts(plan: dict[str, object], label: str) -> None:
_assert_edge_facts(plan, label)
_assert("resize_strategy" in plan or "chamfer_mode" in plan, f"{label}: plan has no strategy marker")
def main() -> int:
for key in (
"first_level_fact_subject_edge_ids",
"first_level_fact_subject_edge_count",
"first_level_fact_adjacent_edge_ids",
"first_level_fact_adjacent_edge_count",
"first_level_fact_included_edge_ids",
"first_level_fact_included_edge_count",
"first_level_vertex_count",
"first_level_adjacent_edge_count",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
model = StepModel.load(DEFAULT_MODEL)
edge_id = _edge_under_test(model)
info = model.edge_info(edge_id)
start = _point(info.get("start_point"))
end = _point(info.get("end_point"))
center = _point(info.get("length_center"))
topology = _assert_edge_topology(model, edge_id)
facts = model.edge_first_level_facts(edge_id)
_assert_edge_facts(facts, "Edge first-level facts")
fields = _EdgeProbe(model)._edge_first_level_selection_fields(edge_id)
_assert_edge_facts(fields, "Edge selection fields")
endpoint_target = (end[0] + 2.0, end[1], end[2])
center_target = (center[0], center[1], center[2] + 1.0)
plans = (
("Edge length", model.general_edge_length_plan(edge_id, 15.0, anchor_mode="keep-start")),
("Edge endpoint", model.edge_endpoint_move_plan(edge_id, "end", endpoint_target)),
("Edge center", model.edge_center_move_plan(edge_id, center_target)),
("Edge fillet", model.edge_fillet_plan(edge_id, 0.5)),
("Edge chamfer", model.edge_chamfer_plan(edge_id, 0.4)),
("Edge asymmetric chamfer", model.edge_asymmetric_chamfer_plan(edge_id, 0.3, 0.4)),
("Edge distance-angle chamfer", model.edge_distance_angle_chamfer_plan(edge_id, 0.3, 45.0)),
)
for label, plan in plans:
_assert_plan_facts(plan, label)
print(f"model={DEFAULT_MODEL}")
print(f"edge_id={edge_id}")
print(f"start={start} end={end}")
print(f"topology={topology}")
print("edge first-level topology ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
SCRIPTS_ROOT = PROJECT_ROOT / "scripts"
if str(SCRIPTS_ROOT) not in sys.path:
sys.path.insert(0, str(SCRIPTS_ROOT))
from step_editor.model import StepModel
from step_editor.window_state import WindowStateMixin
from verify_edge_length_resize import _line_edge_ids_near_length
from verify_hole_resize import _write_through_hole_model
DEFAULT_CUBE = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
class _FakeCombo:
def __init__(self, value: str, text: str) -> None:
self._value = value
self._text = text
def currentData(self) -> str:
return self._value
def currentText(self) -> str:
return self._text
class _EdgeIntentProbe(WindowStateMixin):
def __init__(self, *, strategy: str = "auto", anchor: str = "auto") -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_kind = "edge"
self.selected_face_id = None
self.selected_edge_id = 0
self.selected_part_id = 0
self.selected_solid_id = 0
self.edge_length_strategy_combo = _FakeCombo(strategy, _strategy_label(strategy))
self.edge_length_anchor_combo = _FakeCombo(anchor, _anchor_label(anchor))
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _strategy_label(strategy: str) -> str:
return {
"auto": "自动选择",
"local-edge-only-deform": "只改当前Edge",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径",
"scale-owning-shape-from-edge": "缩放所属",
}.get(strategy, strategy)
def _anchor_label(anchor: str) -> str:
return {
"auto": "自动",
"center": "中心",
"keep-start": "固定起点",
"keep-end": "固定终点",
}.get(anchor, anchor)
def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]:
for spec in specs:
if spec.get("key") == key:
return spec
raise AssertionError(f"{key} spec was not found")
def _scope(spec: dict[str, object], key: str) -> dict[str, object]:
modes = spec.get("scope_modes")
if not isinstance(modes, dict):
raise AssertionError(f"{spec.get('key')} should expose modeling-intent scope modes")
mode = modes.get(key)
if not isinstance(mode, dict):
raise AssertionError(f"{spec.get('key')} has no scope mode {key}")
return mode
def _specs_for_edge(info: dict[str, object], *, strategy: str = "auto", anchor: str = "auto") -> list[dict[str, object]]:
probe = _EdgeIntentProbe(strategy=strategy, anchor=anchor)
specs, _used = probe._editable_property_specs(info)
return specs
def _preselect_value(mode: dict[str, object]) -> str:
preselects = mode.get("preselect_combos")
if not isinstance(preselects, (tuple, list)) or not preselects:
raise AssertionError(f"scope mode should synchronize the hidden Edge strategy combo: {mode}")
first = preselects[0]
if not isinstance(first, dict):
raise AssertionError(f"invalid preselect combo spec: {mode}")
return str(first.get("value") or "")
def _assert_edge_length_scope(spec: dict[str, object], mode_key: str, label: str, *, enabled: bool) -> None:
mode = _scope(spec, mode_key)
_assert(str(mode.get("label")) == label, f"{mode_key} label should be {label!r}, got {mode.get('label')!r}")
_assert(str(mode.get("action")) == "resize_any_edge_length", f"{mode_key} should call resize_any_edge_length")
_assert(str(mode.get("target_attr")) == "edge_target_length_input", f"{mode_key} should target Edge length input")
_assert(bool(mode.get("enabled")) is enabled, f"{mode_key} enabled should be {enabled}, got {mode.get('enabled')}")
_assert(_preselect_value(mode) == mode_key, f"{mode_key} should preselect itself before execution")
hint = f"{mode.get('enabled_tip', '')} {mode.get('range_hint', '')}"
_assert(label in hint or mode_key == "auto", f"{mode_key} should explain its modeling intent")
def _verify_line_edge_intent_specs() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_ids = _line_edge_ids_near_length(model, 10.0, 1e-5)
if not edge_ids:
raise AssertionError("cube has no 10mm line Edge")
info = model.edge_info(edge_ids[0])
specs = _specs_for_edge(info, strategy="move-edge-end-plane-by-push-pull", anchor="keep-start")
_assert(str(_spec(specs, "edge_edit_semantics").get("label")) == "建模意图", "Edge semantic row should be labeled 建模意图")
length_spec = _spec(specs, "length")
_assert(str(length_spec.get("label")) == "长度", f"Edge length label should stay user-facing: {length_spec}")
_assert(
str(length_spec.get("scope_default")) == "move-edge-end-plane-by-push-pull",
f"Edge length should honor selected modeling intent: {length_spec.get('scope_default')}",
)
_assert_edge_length_scope(length_spec, "auto", "自动", enabled=True)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=True)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=True)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
"相邻圆柱",
enabled=False,
)
_assert_edge_length_scope(length_spec, "scale-owning-shape-from-edge", "缩放所属", enabled=True)
anchor_spec = _spec(specs, "edge_length_anchor_mode")
_assert(str(anchor_spec.get("label")) == "长度基准", "line Edge should expose a length anchor row")
print("line Edge modeling-intent specs ok")
def _circle_edge(model: StepModel) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") == "circle" and info.get("radius") and info.get("length"):
return edge_id
raise AssertionError("model has no circular Edge")
def _verify_circle_edge_intent_specs() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_intent_circle_") as temp_dir:
model_path = Path(temp_dir) / "hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
info = model.edge_info(_circle_edge(model))
specs = _specs_for_edge(
info,
strategy="resize-adjacent-cylinder-from-circular-edge-length",
anchor="auto",
)
length_spec = _spec(specs, "length")
_assert(
str(length_spec.get("scope_default")) == "resize-adjacent-cylinder-from-circular-edge-length",
"circular Edge length should honor adjacent-cylinder intent",
)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=False)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=False)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
"相邻圆柱",
enabled=True,
)
radius_spec = _spec(specs, "circle_edge_radius")
radius_modes = radius_spec.get("scope_modes")
_assert(
isinstance(radius_modes, dict)
and set(radius_modes) == {"auto", "resize-adjacent-cylinder-from-circular-edge-length", "scale-owning-shape-from-edge"},
f"circle Edge radius should expose only circular modeling intents: {radius_modes}",
)
for mode_key in radius_modes:
_assert(_preselect_value(radius_modes[mode_key]) == mode_key, f"{mode_key} radius scope should sync strategy")
print("circular Edge modeling-intent specs ok")
def _verify_edge_length_plan_intents() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_id = _line_edge_ids_near_length(model, 10.0, 1e-5)[0]
expectations = (
("local-edge-only-deform", "keep-start", "local-edge-only-deform", "相邻面会自然变斜"),
("move-edge-end-plane-by-push-pull", "keep-start", "move-edge-end-plane-by-push-pull", "正方体这类模型会更像变成长方体"),
("scale-owning-shape-from-edge", "center", "scale-owning-shape-from-edge", "其它尺寸会跟随变化"),
)
for strategy, anchor, expected_strategy, impact_fragment in expectations:
plan = model.general_edge_length_plan(edge_id, 15.0, anchor_mode=anchor, strategy_mode=strategy)
_assert(str(plan.get("status")) != "blocked", f"{strategy}/{anchor} should be available: {plan}")
_assert(
str(plan.get("resize_strategy")) == expected_strategy,
f"{strategy}/{anchor} resolved to {plan.get('resize_strategy')}, expected {expected_strategy}",
)
impact = str(plan.get("edge_length_impact_summary") or "")
_assert(impact_fragment in impact, f"{strategy} impact summary should explain result: {impact}")
blocked = model.general_edge_length_plan(
edge_id,
15.0,
anchor_mode="center",
strategy_mode="move-edge-end-plane-by-push-pull",
)
_assert(str(blocked.get("status")) == "blocked", "moving an end plane while fixing center should be blocked")
_assert("不能使用固定中心" in str(blocked.get("message") or ""), f"blocked message should explain center conflict: {blocked}")
print("Edge modeling-intent plans ok")
def main() -> int:
_verify_line_edge_intent_specs()
_verify_circle_edge_intent_specs()
_verify_edge_length_plan_intents()
print("Edge modeling-intent spec suite passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -34,6 +34,61 @@ def _length_distribution(model: StepModel) -> dict[float, int]:
return dict(sorted(counts.items()))
def _count_near(values: list[float], target: float, tolerance: float) -> int:
return sum(1 for value in values if abs(value - target) <= tolerance)
def _assert_cube_edge_intent_geometry(
*,
source_length: float,
target_length: float,
strategy: str,
anchor: str,
lengths: list[float],
tolerance: float,
) -> None:
target_count = _count_near(lengths, target_length, tolerance)
source_count = _count_near(lengths, source_length, tolerance)
delta = abs(target_length - source_length)
effective_anchor = "keep-start" if anchor == "auto" else anchor
if strategy == "local-edge-only-deform":
if target_count != 1:
raise SystemExit(
"local Edge deformation should only make the selected Edge reach the target length; "
f"target_count={target_count}, lengths={_length_distribution_from_values(lengths)}"
)
if effective_anchor == "center":
expected_slanted = (source_length * source_length + (delta * 0.5) * (delta * 0.5)) ** 0.5
slanted_count = _count_near(lengths, expected_slanted, tolerance)
if source_count != 7 or slanted_count != 4:
raise SystemExit(
"center-anchored local Edge deformation should move both endpoints equally; "
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
else:
expected_slanted = (source_length * source_length + delta * delta) ** 0.5
slanted_count = _count_near(lengths, expected_slanted, tolerance)
if source_count != 9 or slanted_count != 2:
raise SystemExit(
"one-end anchored local Edge deformation should move only the selected Edge endpoint; "
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
elif strategy in {"move-edge-end-plane-by-push-pull", "scale-owning-shape-from-edge"}:
if target_count != 4 or source_count != 8:
raise SystemExit(
f"{strategy} should resize the whole cube span in the selected Edge direction; "
f"target_count={target_count}, source_count={source_count}, "
f"lengths={_length_distribution_from_values(lengths)}"
)
def _length_distribution_from_values(values: list[float]) -> dict[float, int]:
counts = Counter(round(value, 6) for value in values)
return dict(sorted(counts.items()))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify cube edge-length resize semantics.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
@@ -87,6 +142,24 @@ def main() -> int:
error = abs(nearest - args.target_length)
if error > args.tolerance:
raise SystemExit(f"target length check failed: nearest={nearest:g}, error={error:g}")
if (
Path(args.model).resolve() == DEFAULT_MODEL.resolve()
and strategy == "local-edge-only-deform"
and (after.faces != before.faces or after.edges != before.edges)
):
raise SystemExit(
"cube local Edge deformation should not split faces/edges; "
f"before faces/edges={before.faces}/{before.edges}, after={after.faces}/{after.edges}"
)
if Path(args.model).resolve() == DEFAULT_MODEL.resolve():
_assert_cube_edge_intent_geometry(
source_length=args.source_length,
target_length=args.target_length,
strategy=strategy,
anchor=args.anchor,
lengths=lengths,
tolerance=max(args.tolerance, 1e-5),
)
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")
@@ -97,6 +170,7 @@ def main() -> int:
print(f"end_move={plan.get('local_edge_deform_end_move')}")
print(f"before_faces={before.faces} before_edges={before.edges}")
print(f"after_faces={after.faces} after_edges={after.edges}")
print(f"topology_stable={after.faces == before.faces and after.edges == before.edges}")
print(f"nearest_length={nearest:.6f} target_error={error:.6g}")
print(f"length_distribution={_length_distribution(model)}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
SCRIPTS_ROOT = PROJECT_ROOT / "scripts"
if str(SCRIPTS_ROOT) not in sys.path:
sys.path.insert(0, str(SCRIPTS_ROOT))
from step_editor.model import StepModel
from verify_edge_coordinate_edit import _tuple3
from verify_edge_length_resize import _line_edge_ids_near_length
from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model
from verify_hole_resize import _write_through_hole_model
DEFAULT_CUBE = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _assert_blocked(plan: dict[str, object], label: str) -> None:
if str(plan.get("status") or "") != "blocked" or str(plan.get("risk") or "") != "blocked":
raise AssertionError(f"{label} should be blocked, got status={plan.get('status')}, plan={plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if not message.strip():
raise AssertionError(f"{label} should explain why it is blocked: {plan}")
def _cube_edge(model: StepModel) -> int:
edge_ids = _line_edge_ids_near_length(model, 10.0, 1e-5)
if not edge_ids:
raise AssertionError("cube has no 10mm line Edge")
return edge_ids[0]
def _verify_straight_edge_guards() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_id = _cube_edge(model)
info = model.edge_info(edge_id)
current_length = float(info.get("length") or 0.0)
start = _tuple3(info.get("start_point"), "edge start")
end = _tuple3(info.get("end_point"), "edge end")
center = _tuple3(info.get("length_center"), "edge center")
_assert_blocked(model.general_edge_length_plan(edge_id, current_length), "same Edge length")
_assert_blocked(model.general_edge_length_plan(edge_id, 0.0), "zero Edge length")
_assert_blocked(model.general_edge_length_plan(edge_id, -1.0), "negative Edge length")
_assert_blocked(model.edge_endpoint_move_plan(edge_id, "start", start), "same start point")
_assert_blocked(model.edge_endpoint_move_plan(edge_id, "end", end), "same end point")
_assert_blocked(model.edge_endpoint_move_plan(edge_id, "start", end), "collapsed endpoint")
_assert_blocked(model.edge_center_move_plan(edge_id, center), "same Edge center")
_assert_blocked(model.edge_fillet_plan(edge_id, 0.0), "zero Edge fillet radius")
_assert_blocked(model.edge_fillet_plan(edge_id, current_length * 0.45), "oversized Edge fillet radius")
_assert_blocked(model.edge_chamfer_plan(edge_id, 0.0), "zero Edge chamfer distance")
_assert_blocked(model.edge_chamfer_plan(edge_id, current_length * 0.45), "oversized Edge chamfer distance")
_assert_blocked(model.edge_asymmetric_chamfer_plan(edge_id, 0.0, 0.5), "zero asymmetric chamfer distance")
_assert_blocked(
model.edge_asymmetric_chamfer_plan(edge_id, current_length * 0.45, 0.5),
"oversized asymmetric chamfer distance",
)
_assert_blocked(model.edge_distance_angle_chamfer_plan(edge_id, 0.0, 45.0), "zero distance-angle chamfer")
_assert_blocked(model.edge_distance_angle_chamfer_plan(edge_id, 0.5, 0.0), "zero distance-angle angle")
_assert_blocked(model.edge_distance_angle_chamfer_plan(edge_id, 0.5, 90.0), "too large distance-angle angle")
print("straight Edge target guards ok")
def _verify_ellipse_guards() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_guard_ellipse_") as temp_dir:
model_path = Path(temp_dir) / "ellipse.step"
_write_ellipse_face_model(model_path)
model = StepModel.load(model_path)
edge_ids = _ellipse_edge_ids(model)
if not edge_ids:
raise AssertionError("ellipse model has no ellipse Edge")
edge_id = edge_ids[0]
info = model.edge_info(edge_id)
major = float(info.get("major_radius") or 0.0)
minor = float(info.get("minor_radius") or 0.0)
_assert_blocked(model.ellipse_edge_axis_radius_plan(edge_id, major, axis_kind="major"), "same ellipse major radius")
_assert_blocked(model.ellipse_edge_axis_radius_plan(edge_id, minor, axis_kind="minor"), "same ellipse minor radius")
_assert_blocked(model.ellipse_edge_axis_radius_plan(edge_id, 0.0, axis_kind="major"), "zero ellipse major radius")
_assert_blocked(model.ellipse_edge_axis_radius_plan(edge_id, -1.0, axis_kind="minor"), "negative ellipse minor radius")
print("ellipse Edge target guards ok")
def _verify_circular_axis_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_guard_circle_") as temp_dir:
model_path = Path(temp_dir) / "hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
center = info.get("center")
if center is None:
continue
_assert_blocked(model.circular_edge_axis_move_plan(edge_id, center), "same circular Edge center")
print("circular Edge axis target guard ok")
return
raise AssertionError("hole model has no circular Edge with center")
def main() -> int:
_verify_straight_edge_guards()
_verify_ellipse_guards()
_verify_circular_axis_guard()
print("Edge target guard suite passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -9,6 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
from step_editor.model import StepModel
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
from scripts.verify_large_stepped_cap_push_pull import _large_multi_boundary_cap_face, _large_stepped_cap_face
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
@@ -26,7 +27,7 @@ class _Probe(WindowStateMixin):
pass
def _feature_rows(model: StepModel, face_id: int) -> tuple[str, ...]:
def _feature_context(model: StepModel, face_id: int) -> dict[str, object]:
info = model.quick_face_info(face_id)
probe = object.__new__(_Probe)
probe.model = model
@@ -41,7 +42,24 @@ def _feature_rows(model: StepModel, face_id: int) -> tuple[str, ...]:
probe.feature_detection_level = "current-only"
probe.manual_bottom_face_id = None
probe.manual_slot_pair_face_id = None
context = probe._feature_context_info(face_id)
return probe._feature_context_info(face_id)
def _feature_rows(model: StepModel, face_id: int) -> tuple[str, ...]:
context = _feature_context(model, face_id)
probe = object.__new__(_Probe)
probe.model = model
probe.operation_in_progress = False
probe.scan_in_progress = False
probe.load_in_progress = False
probe.selected_face_id = face_id
probe.selected_edge_id = None
probe.selected_kind = "feature"
probe.selected_part_id = int(context["part_id"])
probe.selected_solid_id = int(context["solid_id"])
probe.feature_detection_level = "current-only"
probe.manual_bottom_face_id = None
probe.manual_slot_pair_face_id = None
specs, _used = probe._editable_property_specs(context)
rows = probe._feature_context_property_specs(specs, context)
return tuple(str(row.get("key")) for row in rows if row.get("parameter_role") == "dimension")
@@ -53,6 +71,47 @@ def _assert_contains(keys: tuple[str, ...], expected: tuple[str, ...], label: st
raise AssertionError(f"{label} missing {missing}, got {keys}")
def _assert_face_594_stays_stable_after_remote_cap_edit() -> None:
model = StepModel.load(MODEL_PATH)
source_face_id = _large_stepped_cap_face(model)
target_face_id = _large_multi_boundary_cap_face(model)
if target_face_id != FACE_ID:
raise AssertionError(f"expected large multi-boundary cap to be Face {FACE_ID}, got {target_face_id}")
target_logical_id = model.face_region_logical_id(target_face_id)
before_rows = _feature_rows(model, target_face_id)
before_info = model.quick_face_info(target_face_id)
model.push_pull_face(source_face_id, 89.0)
resolved_face_id = model.resolve_face_selection_id(target_logical_id)
if resolved_face_id is None:
raise AssertionError(f"remote cap edit lost logical Face {target_logical_id}")
after_info = model.quick_face_info(resolved_face_id)
if after_info.get("surface") != "plane":
raise AssertionError(
f"logical Face {target_logical_id} should still point to the multi-boundary plane, "
f"got Face {resolved_face_id}: {after_info}"
)
if int(after_info.get("inner_boundary_wires", 0) or 0) < int(before_info.get("inner_boundary_wires", 0) or 0):
raise AssertionError(
f"logical Face {target_logical_id} lost inner boundary wires after remote edit: "
f"before={before_info}, after={after_info}"
)
after_rows = _feature_rows(model, resolved_face_id)
if after_rows != before_rows:
raise AssertionError(
"Face 594 current-only feature rows changed after editing a remote cylindrical cap: "
f"before={before_rows}, after={after_rows}, resolved={resolved_face_id}"
)
context = _feature_context(model, resolved_face_id)
if context.get("selection_display_id") != target_logical_id:
raise AssertionError(f"feature context should keep logical Face ID {target_logical_id}: {context}")
if context.get("selection_topological_face_id") != resolved_face_id:
raise AssertionError(f"feature context should expose current topological Face {resolved_face_id}: {context}")
def main() -> int:
model = StepModel.load(MODEL_PATH)
if FACE_ID >= len(model.faces):
@@ -80,6 +139,8 @@ def main() -> int:
f"before={before_cached_rows}, after={after_cached_rows}"
)
_assert_face_594_stays_stable_after_remote_cap_edit()
print("Face feature parameter consistency ok")
return 0
+1 -1
View File
@@ -374,7 +374,7 @@ def main() -> int:
input_path=hollow_path,
)
_run_worker_case(
label="偏移曲面(局部重建)",
label="偏移变换(局部重建)",
operation="move_face_plane_offset_local",
args=[0, 10.0],
validator=_assert_plane_position,
+181 -51
View File
@@ -37,6 +37,15 @@ def _specs(info: dict[str, object]) -> list[dict[str, object]]:
return specs
def _edge_specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
probe.selected_kind = "edge"
probe.selected_face_id = None
probe.selected_edge_id = 7
specs, _used = probe._editable_property_specs(info)
return specs
def _display_specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
probe.selected_kind = "face"
@@ -80,6 +89,19 @@ def _assert_label(specs: list[dict[str, object]], key: str, label: str) -> None:
raise SystemExit(f"{key} label should be {label!r}, got {actual!r}")
def _assert_current_text_contains(
specs: list[dict[str, object]],
key: str,
fragments: tuple[str, ...],
label: str,
) -> None:
spec = _spec(specs, key)
text = str(spec.get("current_text") or "")
missing = [fragment for fragment in fragments if fragment not in text]
if missing:
raise SystemExit(f"{label} {key} text missing {missing}: {text!r}")
def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None:
spec = _spec(specs, key)
actual_low = spec.get("min_value")
@@ -167,6 +189,8 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[
seen_non_actionable = False
front_keys: list[str] = []
for spec in display_specs:
if bool(spec.get("pin_top")):
continue
key = str(spec.get("key", ""))
if _is_actionable_edit_spec(spec):
if seen_non_actionable:
@@ -368,6 +392,25 @@ def main() -> int:
}
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
_assert_current_text_contains(
plane_specs,
"cad_modeling_form",
("柔性建模", "拉伸切除", "偏移变换"),
"plane Face",
)
_assert_label(plane_specs, "cad_recommended_operation", "推荐操作")
_assert_current_text_contains(
plane_specs,
"cad_recommended_operation",
("优先改偏移变换", "拉伸/切除"),
"plane Face",
)
plane_display_specs = _display_specs(plane_info)
if str(plane_display_specs[0].get("key", "")) != "cad_modeling_form":
raise SystemExit(f"plane Face should show CAD modeling form first: {plane_display_specs[0]}")
if str(plane_display_specs[1].get("key", "")) != "cad_recommended_operation":
raise SystemExit(f"plane Face should show recommended operation second: {plane_display_specs[1]}")
_assert_contains(
plane_keys,
{
@@ -381,7 +424,7 @@ def main() -> int:
)
_assert_label(plane_specs, "local_face_width", "U向尺寸")
_assert_label(plane_specs, "local_face_height", "V向尺寸")
_assert_label(plane_specs, "face_target_normal_position", "偏移曲面")
_assert_label(plane_specs, "face_target_normal_position", "偏移变换")
_assert_actionable_rows_first(
plane_info,
(
@@ -404,6 +447,13 @@ def main() -> int:
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}")
_assert_label(plane_feature_rows, "face_edit_semantics", "建模意图")
_assert_label(plane_feature_rows, "cad_modeling_form", "建模形式")
_assert_label(plane_feature_rows, "cad_recommended_operation", "推荐操作")
if str(plane_feature_rows[0].get("key", "")) != "cad_modeling_form":
raise SystemExit(f"feature mode should keep CAD modeling form first: {plane_feature_rows[0]}")
if str(plane_feature_rows[1].get("key", "")) != "cad_recommended_operation":
raise SystemExit(f"feature mode should keep recommended operation second: {plane_feature_rows[1]}")
expected_plane_feature_keys = {
"area",
"local_face_width",
@@ -512,24 +562,36 @@ def main() -> int:
if shell_owning.get("label") != "缩放特征":
raise SystemExit(f"shell thickness owning scope should expose an edit semantic, got {shell_owning}")
cylinder_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"area": 188.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),
"axis_center": (0.0, 0.0, 5.0),
"feature_bottom_face_ids": (),
}
)
cylinder_info = {
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"area": 188.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),
"axis_center": (0.0, 0.0, 5.0),
"feature_bottom_face_ids": (),
}
cylinder_specs = _specs(cylinder_info)
cylinder_keys = {str(spec.get("key", "")) for spec in cylinder_specs}
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
_assert_current_text_contains(
cylinder_specs,
"cad_modeling_form",
("工程特征", "", "重切"),
"cylindrical hole feature",
)
_assert_current_text_contains(
cylinder_specs,
"cad_recommended_operation",
("孔径", "盲孔", "轴心"),
"cylindrical hole feature",
)
cylinder_topology_info = {
"surface": "cylinder",
@@ -646,30 +708,42 @@ def main() -> int:
if "slot_chord_width_estimate" in split_full_hole_keys:
raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}")
slot_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 3.141592653589793,
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"area": 188.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),
"axis_center": (0.0, 0.0, 5.0),
}
)
slot_info = {
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 3.141592653589793,
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"area": 188.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),
"axis_center": (0.0, 0.0, 5.0),
}
slot_specs = _specs(slot_info)
slot_keys = {str(spec.get("key", "")) for spec in slot_specs}
_assert_no_generic_face_leak(slot_keys, "slot/half-hole feature")
_assert_contains(
slot_keys,
{"slot_chord_width_estimate", "slot_sagitta_depth_estimate", "slot_arc_length_estimate"},
"slot/half-hole feature",
)
_assert_current_text_contains(
slot_specs,
"cad_modeling_form",
("工程特征", "", "局部重建"),
"slot/half-hole feature",
)
_assert_current_text_contains(
slot_specs,
"cad_recommended_operation",
("槽宽", "槽深", "轴心"),
"slot/half-hole feature",
)
boss_info = {
"surface": "cylinder",
@@ -691,30 +765,42 @@ def main() -> int:
boss_keys = {str(spec.get("key", "")) for spec in boss_specs}
_assert_no_generic_face_leak(boss_keys, "boss feature")
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
_assert_current_text_contains(
boss_specs,
"cad_modeling_form",
("工程特征", "凸台", "局部重建"),
"boss feature",
)
boss_height = _spec(boss_specs, "boss_height")
if boss_height.get("scope_default") != "owning":
raise SystemExit(f"boss height should default to owning-axis resize: {boss_height}")
if boss_height.get("action") != "resize_cylindrical_height_owning_scale":
raise SystemExit(f"boss height should use owning-axis action by default: {boss_height}")
fillet_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"diameter": 2.0,
"radius": 1.0,
"angular_span": 1.5707963267948966,
"existing_fillet_radius": 1.0,
"feature_existing_fillet_support_face_ids": (1, 2),
"area": 3.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),
}
)
fillet_info = {
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"diameter": 2.0,
"radius": 1.0,
"angular_span": 1.5707963267948966,
"existing_fillet_radius": 1.0,
"feature_existing_fillet_support_face_ids": (1, 2),
"area": 3.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),
}
fillet_specs = _specs(fillet_info)
fillet_keys = {str(spec.get("key", "")) for spec in fillet_specs}
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature")
_assert_current_text_contains(
fillet_specs,
"cad_modeling_form",
("工程特征", "倒圆角", "重新倒圆"),
"existing fillet feature",
)
analytic_cases = (
(
@@ -766,6 +852,27 @@ def main() -> int:
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_current_text_contains(
specs,
"cad_modeling_form",
("工程特征", "拔模", "锥孔"),
label,
)
elif label == "sphere feature":
_assert_current_text_contains(
specs,
"cad_modeling_form",
("解析曲面", "缩放特征", "球面"),
label,
)
elif label == "torus feature":
_assert_current_text_contains(
specs,
"cad_modeling_form",
("解析曲面", "缩放特征", "环面"),
label,
)
if label == "cone feature":
_assert_label(specs, "cone_reference_radius", "参考半径")
_assert_label(specs, "cone_reference_diameter", "参考直径")
@@ -775,6 +882,29 @@ def main() -> int:
if surface_spec.get("current_text") != "圆锥面 / 拔模面":
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}")
edge_specs = _edge_specs(
{
"curve": "line",
"length": 10.0,
"start_point": (0.0, 0.0, 0.0),
"end_point": (10.0, 0.0, 0.0),
"length_center": (5.0, 0.0, 0.0),
}
)
_assert_current_text_contains(
edge_specs,
"cad_modeling_form",
("柔性建模", "移动几何", "直线 Edge"),
"line Edge",
)
_assert_current_text_contains(
edge_specs,
"cad_recommended_operation",
("优先改长度", "移动端面", "只改当前Edge"),
"line Edge",
)
_assert_contains({str(spec.get("key", "")) for spec in edge_specs}, {"length"}, "line Edge")
low_recognition_specs = _specs(
{
"surface": "sphere",