feat: 完善 Edge 一级编辑与 CAD 建模语义
This commit is contained in:
@@ -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",),
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user