from __future__ import annotations from pathlib import Path import re 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.window_state import WindowStateMixin from step_editor.ui_helpers import INFO_LABELS, _format_value class _PropertySpecProbe(WindowStateMixin): def __init__(self) -> None: self.model = object() self.operation_in_progress = False self.scan_in_progress = False self.load_in_progress = False self.selected_face_id = 1 self.selected_edge_id = None self.selected_kind = "feature" self.selected_part_id = 1 self.selected_solid_id = 1 self.manual_bottom_face_id = None self.manual_slot_pair_face_id = None def _spec_keys(info: dict[str, object]) -> set[str]: return {str(spec.get("key", "")) for spec in _specs(info)} def _specs(info: dict[str, object]) -> list[dict[str, object]]: probe = _PropertySpecProbe() specs, _used = probe._editable_property_specs(info) 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" specs = probe._property_editor_specs(info, info) return probe._sort_property_specs_for_display(specs) def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]: for spec in specs: if spec.get("key") != key: continue modes = spec.get("scope_modes") if not isinstance(modes, dict) or mode not in modes: raise SystemExit(f"{key} has no scope mode {mode}") selected = modes[mode] if not isinstance(selected, dict): raise SystemExit(f"{key} scope mode {mode} is invalid") return selected raise SystemExit(f"{key} spec was not found") def _scoped_effective_spec(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]: spec = dict(_spec(specs, key)) base_label = str(spec.get("label", "")) spec.update(_scope_mode(specs, key, mode)) spec["label"] = base_label return spec def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]: for spec in specs: if spec.get("key") == key: return spec raise SystemExit(f"{key} spec was not found") def _assert_label(specs: list[dict[str, object]], key: str, label: str) -> None: spec = _spec(specs, key) actual = str(spec.get("label") or "") if actual != label: 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") actual_high = spec.get("max_value") if actual_low is None or actual_high is None: raise SystemExit(f"{key} should expose a hard target range, got {spec}") if abs(float(actual_low) - low) > 1e-7 or abs(float(actual_high) - high) > 1e-7: raise SystemExit( f"{key} hard range should be {low:g}..{high:g}, " f"got {float(actual_low):g}..{float(actual_high):g}" ) def _assert_vector_distance_limit(spec: dict[str, object], high: float, label: str) -> None: actual = spec.get("max_vector_distance") reference = spec.get("vector_distance_reference") if actual is None or reference is None: raise SystemExit(f"{label} should expose a vector distance limit, got {spec}") if abs(float(actual) - high) > 1e-7: raise SystemExit(f"{label} vector distance limit should be {high:g}, got {float(actual):g}") def _assert_validation_error( probe: _PropertySpecProbe, spec: dict[str, object], text: str, should_error: bool, label: str, ) -> None: error = probe._property_target_validation_error(spec, text) if should_error and not error: raise SystemExit(f"{label} should be rejected") if not should_error and error: raise SystemExit(f"{label} should be accepted, got {error}") def _assert_hint_fragments(hint: object, fragments: tuple[str, ...], label: str) -> None: hint_text = str(hint or "") missing = [fragment for fragment in fragments if fragment not in hint_text] if missing: raise SystemExit(f"{label} range hint missing {missing}: {hint_text}") def _assert_scoped_hint_fragments( specs: list[dict[str, object]], key: str, mode: str, fragments: tuple[str, ...], ) -> None: selected = _scope_mode(specs, key, mode) _assert_hint_fragments(selected.get("range_hint"), fragments, f"{key}/{mode}") def _assert_contains(keys: set[str], required: set[str], label: str) -> None: missing = sorted(required - keys) if missing: raise SystemExit(f"{label} edit specs missing: {missing}") def _assert_no_generic_face_leak(keys: set[str], label: str) -> None: forbidden = { "area", "face_center_position", "local_face_width", "local_face_height", "face_target_normal_position", "push_pull_distance", } leaked = sorted(forbidden & keys) if leaked: raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}") def _is_actionable_edit_spec(spec: dict[str, object]) -> bool: return ( bool(spec.get("editable")) and bool(spec.get("enabled")) and bool(spec.get("action")) and str(spec.get("value_type", "number")) != "command" ) def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[str, ...], label: str) -> None: display_specs = _display_specs({**info, "readonly_probe_for_ordering": "readonly"}) seen_non_actionable = False front_keys: list[str] = [] for spec in display_specs: if bool(spec.get("pin_top")): continue key = str(spec.get("key", "")) if _is_actionable_edit_spec(spec): if seen_non_actionable: raise SystemExit(f"{label}: actionable row {key!r} appeared after a read-only/non-action row") front_keys.append(key) else: seen_non_actionable = True missing = [key for key in expected_keys if key not in front_keys] if missing: raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}") def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]: legacy_terms = ( "面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离", "面偏移", "面宽", "面高", "推拉当前面", "只改当前面", "调整整个特征", "移动整个特征", ) hits: list[str] = [] if isinstance(value, dict): for key, child in value.items(): # Internal keys/action names still use width/height/offset; only user-visible text is checked. if key in { "key", "action", "target_attr", "target_attrs", "target_transform", "transform_context", "used", }: continue hits.extend(_collect_legacy_face_terms(child, f"{path}.{key}")) elif isinstance(value, (list, tuple)): for index, child in enumerate(value): hits.extend(_collect_legacy_face_terms(child, f"{path}[{index}]")) elif isinstance(value, str): for term in legacy_terms: if term in value: hits.append(f"{path}: {term} in {value!r}") return hits def _assert_no_legacy_face_terms(specs: list[dict[str, object]]) -> None: hits = _collect_legacy_face_terms(specs) if hits: raise SystemExit("legacy Face UI terms should not appear in property specs: " + "; ".join(hits)) def _assert_no_legacy_face_source_terms() -> None: files = ( PROJECT_ROOT / "README.md", PROJECT_ROOT / "step_editor" / "app.py", PROJECT_ROOT / "step_editor" / "ui_helpers.py", PROJECT_ROOT / "step_editor" / "window_state.py", PROJECT_ROOT / "step_editor" / "window_actions.py", PROJECT_ROOT / "step_editor" / "operations.py", PROJECT_ROOT / "scripts" / "verify_isolated_face_edit.py", ) patterns = ( re.compile(r"面内尺寸 1/2"), re.compile(r"面内尺寸 1"), re.compile(r"面内尺寸 2"), re.compile(r"(? None: probe = _PropertySpecProbe() number_spec = { "label": "面积", "current_raw": 100.0, "current_text": "100", "value_type": "positive", } if probe._property_target_changed(number_spec, "100"): raise SystemExit("unchanged numeric Face target was treated as changed") if not probe._property_target_changed(number_spec, "101"): raise SystemExit("changed numeric Face target was not detected") vector_spec = { "label": "中心", "current_raw": (5.0, 5.0, 0.0), "current_text": "5, 5, 0", "value_type": "vector3", } if probe._property_target_changed(vector_spec, "5,5,0"): raise SystemExit("unchanged vector Face target was treated as changed") if not probe._property_target_changed(vector_spec, "5,5,1"): raise SystemExit("changed vector Face target was not detected") def _assert_holed_plane_local_scopes_disabled() -> None: specs = _specs( { "surface": "plane", "area": 280.0, "area_center": (15.0, 10.0, 8.0), "bbox_center": (15.0, 10.0, 8.0), "local_face_width": 30.0, "local_face_height": 20.0, "plane_origin": (0.0, 0.0, 8.0), "push_pull_outward_direction": (0.0, 0.0, 1.0), "normal": (0.0, 0.0, 1.0), "boundary_wires": 2, "inner_boundary_wires": 1, "has_inner_boundaries": True, "local_face_deform_ready": False, "local_face_deform_blocker": "has inner boundary", } ) for key in ("area", "local_face_width", "local_face_height", "face_center_position"): local_mode = _scope_mode(specs, key, "local") if bool(local_mode.get("enabled", True)): raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face") disabled_tip = str(local_mode.get("disabled_tip") or "") if "has inner boundary" not in disabled_tip: raise SystemExit(f"{key} local disabled tip should explain the blocker: {disabled_tip}") owning_mode = _scope_mode(specs, key, "owning") if not bool(owning_mode.get("enabled", False)): raise SystemExit(f"{key} owning scope should remain available for a holed planar Face") offset_local = _scope_mode(specs, "face_target_normal_position", "local") if bool(offset_local.get("enabled", True)): raise SystemExit("Face plane-offset local scope should be disabled for a holed planar Face") offset_tip = str(offset_local.get("disabled_tip") or "") if "has inner boundary" not in offset_tip: raise SystemExit(f"Face plane-offset local disabled tip should explain the blocker: {offset_tip}") offset_push_pull = _scope_mode(specs, "face_target_normal_position", "push_pull") if not bool(offset_push_pull.get("enabled", False)): raise SystemExit("Face plane-offset push/pull scope should remain available for a holed planar Face") semantics = _spec(specs, "face_edit_semantics") if "不能局部重建" not in str(semantics.get("current_text") or ""): raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}") if "has inner boundary" not in str(semantics.get("disabled_tip") or ""): raise SystemExit(f"Face edit semantics tip should include the blocker: {semantics}") def main() -> int: if INFO_LABELS.get("face_id") != "当前拓扑 Face ID": raise SystemExit("raw face_id label should make clear that it is the current topological Face ID") for key in ("selection_title", "selection_display_id", "selection_topological_face_id"): if key not in INFO_LABELS: raise SystemExit(f"{key} should have a user-facing label") relation_depths_text = _format_value(("second-level", "third-level", "deeper")) if "second-level" not in relation_depths_text or "deeper" not in relation_depths_text: raise SystemExit(f"relation-depth tuple should render as text: {relation_depths_text!r}") plane_info = { "surface": "plane", "area": 100.0, "area_center": (5.0, 5.0, 0.0), "bbox_center": (5.0, 5.0, 0.0), "bbox_diagonal": 14.1421356237, "local_face_width": 10.0, "local_face_height": 10.0, "plane_origin": (0.0, 0.0, 0.0), "push_pull_outward_direction": (0.0, 0.0, 1.0), "normal": (0.0, 0.0, 1.0), "topology_relation_depth": 1, "topology_relation_status": "ready", "same_domain_face_count": 1, "first_level_boundary_edge_count": 4, "first_level_boundary_vertex_count": 4, "first_level_adjacent_face_count": 4, "first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。", "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", } plane_specs = _specs(plane_info) plane_keys = {str(spec.get("key", "")) for spec in plane_specs} _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, { "area", "local_face_width", "local_face_height", "face_center_position", "face_target_normal_position", }, "plane Face", ) _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_actionable_rows_first( plane_info, ( "area", "local_face_width", "local_face_height", "face_center_position", "face_target_normal_position", ), "plane Face display order", ) _assert_no_legacy_face_terms(plane_specs) plane_feature_probe = _PropertySpecProbe() plane_feature_probe.selected_kind = "feature" plane_feature_specs, _used = plane_feature_probe._editable_property_specs(plane_info) plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info) plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows} topology_spec = _spec(plane_feature_rows, "face_first_level_topology") topology_text = str(topology_spec.get("current_text") or "") for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"): if fragment not in topology_text: raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}") _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", "local_face_height", "face_center_position", "face_target_normal_position", } missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys if missing_plane_feature_keys: raise SystemExit( "plain plane feature mode should expose the same first-level editable Face parameters; " f"missing {sorted(missing_plane_feature_keys)}, " f"got {sorted(plane_feature_keys)}" ) if "no_editable_feature_dimensions" in plane_feature_keys: raise SystemExit("plane feature mode should not fall back to no editable dimensions") _assert_hard_range(plane_specs, "area", 0.25, 2500.0) _assert_hard_range(plane_specs, "local_face_width", 0.5, 50.0) _assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0) _assert_hard_range(plane_specs, "face_target_normal_position", -70.7106781185, 70.7106781185) probe = _PropertySpecProbe() _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.49", True, "Face width below hard range") _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.5", False, "Face width lower boundary") _assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "50.1", True, "Face width above hard range") _assert_validation_error(probe, _spec(plane_specs, "area"), "0.2", True, "Face area below hard range") _assert_validation_error(probe, _spec(plane_specs, "area"), "2500", False, "Face area upper boundary") _assert_validation_error( probe, _spec(plane_specs, "face_target_normal_position"), "-71", True, "Face offset below hard range", ) center_local = _scoped_effective_spec(plane_specs, "face_center_position", "local") center_owning = _scoped_effective_spec(plane_specs, "face_center_position", "owning") _assert_vector_distance_limit(center_local, 70.7106781185, "Face center local") _assert_vector_distance_limit(center_owning, 70.7106781185, "Face center owning") _assert_validation_error( probe, center_local, "75.7106781185, 5, 0", False, "Face center target at distance boundary", ) _assert_validation_error( probe, center_local, "76, 5, 0", True, "Face center target beyond distance limit", ) if "push_pull_distance" in plane_keys: raise SystemExit("plane Face should not expose a separate push_pull_distance row") for key in ("area", "local_face_width", "local_face_height"): for mode in ("local", "owning"): _assert_scoped_hint_fragments( plane_specs, key, mode, ("5%", "5 倍", "会被阻止"), ) for mode in ("push_pull", "local", "owning"): _assert_scoped_hint_fragments( plane_specs, "face_target_normal_position", mode, ("会被阻止",), ) for mode in ("local", "owning"): _assert_scoped_hint_fragments( plane_specs, "face_center_position", mode, ("5 倍", "会被阻止"), ) shell_specs = _specs( { **plane_info, "shell_region_status": "candidate", "shell_thickness_estimate": 2.0, "shell_current_thickness": 2.0, "shell_signed_thickness": 2.0, "shell_opposite_face_id": 2, "shell_overlap_ratio_estimate": 1.0, } ) _assert_hard_range(shell_specs, "shell_thickness_estimate", 0.1, 10.0) _assert_validation_error( probe, _spec(shell_specs, "shell_thickness_estimate"), "0.05", True, "thin wall thickness below hard range", ) for mode in ("local", "owning"): _assert_scoped_hint_fragments( shell_specs, "shell_thickness_estimate", mode, ("5%", "5 倍", "会被阻止"), ) shell_local = _scope_mode(shell_specs, "shell_thickness_estimate", "local") if shell_local.get("label") != "拉伸/切除": raise SystemExit(f"shell thickness local scope should expose an edit semantic, got {shell_local}") shell_owning = _scope_mode(shell_specs, "shell_thickness_estimate", "owning") if shell_owning.get("label") != "缩放特征": raise SystemExit(f"shell thickness owning scope should expose an edit semantic, got {shell_owning}") 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", "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), "topology_relation_depth": 1, "topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level", "topology_relation_status": "ready", "topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。", "cylindrical_feature_side_face_count": 1, "cylindrical_feature_boundary_edge_count": 2, "cylindrical_feature_boundary_vertex_count": 4, "cylindrical_feature_adjacent_face_count": 2, "cylindrical_feature_end_face_count": 2, "cylindrical_feature_opening_face_count": 2, "first_level_topology_note": "Cylindrical side Faces=1, boundary Edges=2, boundary Vertices=4, direct adjacent Faces=2.", } cylinder_topology_specs = _specs(cylinder_topology_info) cylinder_topology_spec = _spec(cylinder_topology_specs, "cylindrical_feature_first_level_topology") cylinder_topology_text = str(cylinder_topology_spec.get("current_text") or "") for fragment in ("侧壁 Face 1 个", "边界 Edge 2 条", "共享边相邻 Face 2 个"): if fragment not in cylinder_topology_text: raise SystemExit(f"cylindrical feature topology row should explain first-level counts: {cylinder_topology_spec}") if "二级、三级" not in str(cylinder_topology_spec.get("disabled_tip") or ""): raise SystemExit(f"cylindrical feature topology tip should document ignored deeper topology: {cylinder_topology_spec}") cylinder_feature_probe = _PropertySpecProbe() cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info) cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info) _spec(cylinder_feature_rows, "cylindrical_feature_first_level_topology") generic_cylinder_specs = _specs( { "surface": "cylinder", "feature_guess": "", "diameter": 6.0, "radius": 3.0, "angular_span": 6.283185307179586, "height_estimate": 11.0, "same_domain_height_estimate": 11.0, "area": 207.0, "area_center": (0.0, 0.0, 5.5), "bbox_center": (0.0, 0.0, 5.5), "axis": (0.0, 0.0, 1.0), "axis_point": (0.0, 0.0, 0.0), "axis_center": (0.0, 0.0, 5.5), } ) generic_height = _spec(generic_cylinder_specs, "cylinder_height") if generic_height.get("scope_default") != "owning": raise SystemExit(f"generic cylinder height should default to owning-axis resize: {generic_height}") if generic_height.get("action") != "resize_cylindrical_height_owning_scale": raise SystemExit(f"generic cylinder height should use owning-axis action by default: {generic_height}") if generic_height.get("scope_text") != "缩放特征": raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}") split_full_cylinder_specs = _specs( { "surface": "cylinder", "feature_guess": "", "diameter": 6.0, "radius": 3.0, "angular_span": 3.141592653589793, "same_domain_angular_span": 6.283185307179586, "is_full_cylinder": True, "height_estimate": 11.0, "same_domain_height_estimate": 11.0, "area": 207.0, "area_center": (0.0, 0.0, 5.5), "bbox_center": (0.0, 0.0, 5.5), "axis": (0.0, 0.0, 1.0), "axis_point": (0.0, 0.0, 0.0), "axis_center": (0.0, 0.0, 5.5), } ) split_full_height_local = _scope_mode(split_full_cylinder_specs, "cylinder_height", "local") if not bool(split_full_height_local.get("enabled")): raise SystemExit( "split same-domain full cylinder should keep local height push/pull enabled: " f"{split_full_height_local}" ) split_full_hole_keys = _spec_keys( { "surface": "cylinder", "feature_guess": "hole/groove candidate", "diameter": 6.0, "radius": 3.0, "angular_span": 3.141592653589793, "same_domain_angular_span": 6.283185307179586, "is_full_cylinder": True, "height_estimate": 11.0, "same_domain_height_estimate": 11.0, "area": 188.0, "area_center": (0.0, 0.0, 5.5), "bbox_center": (0.0, 0.0, 5.5), "axis": (0.0, 0.0, 1.0), "axis_point": (0.0, 0.0, 0.0), "axis_center": (0.0, 0.0, 5.5), "slot_chord_width_estimate": 6.0, "slot_sagitta_depth_estimate": 3.0, "slot_arc_length_estimate": 9.42477796076938, "feature_bottom_face_ids": (), } ) _assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole") 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_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", "feature_guess": "boss/outer-round candidate", "diameter": 6.0, "radius": 3.0, "angular_span": 6.283185307179586, "height_estimate": 5.0, "same_domain_height_estimate": 5.0, "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_start_end_face_ids": (1,), } boss_specs = _specs(boss_info) 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_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 = ( ( "cone feature", { "surface": "cone", "reference_radius": 4.0, "reference_diameter": 8.0, "semi_angle_degrees": 12.0, "area": 50.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), }, {"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"}, ), ( "sphere feature", { "surface": "sphere", "radius": 5.0, "diameter": 10.0, "area": 100.0, "area_center": (0.0, 0.0, 0.0), "bbox_center": (0.0, 0.0, 0.0), "center": (0.0, 0.0, 0.0), }, {"sphere_radius", "sphere_diameter"}, ), ( "torus feature", { "surface": "torus", "major_radius": 8.0, "minor_radius": 2.0, "major_diameter": 16.0, "minor_diameter": 4.0, "area": 100.0, "area_center": (0.0, 0.0, 0.0), "bbox_center": (0.0, 0.0, 0.0), "center": (0.0, 0.0, 0.0), }, {"torus_major_radius", "torus_minor_radius"}, ), ) for label, info, required in analytic_cases: specs = _specs(info) keys = {str(spec.get("key", "")) for spec in specs} _assert_no_generic_face_leak(keys, label) _assert_contains(keys, required, label) if label == "cone feature": _assert_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", "参考直径") _assert_label(specs, "cone_semi_angle_degrees", "圆锥半角") display_specs = _display_specs(info) surface_spec = _spec(display_specs, "surface") if surface_spec.get("current_text") != "圆锥面 / 拔模面": raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}") 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", "radius": 5.0, "diameter": 10.0, "area": 100.0, "area_center": (0.0, 0.0, 0.0), "bbox_center": (0.0, 0.0, 0.0), "center": (0.0, 0.0, 0.0), "recognition_score": 24, "recognition_confidence": "low", "recognition_risk": "medium", "recognition_blockers": "ambiguous analytic surface", } ) for key in ("sphere_radius", "sphere_diameter"): spec = _spec(low_recognition_specs, key) if spec.get("enabled"): raise SystemExit(f"{key} should be read-only when analytic surface recognition is low: {spec}") if "ambiguous analytic surface" not in str(spec.get("disabled_tip") or ""): raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}") _assert_target_change_detection() _assert_holed_plane_local_scopes_disabled() _assert_no_legacy_face_source_terms() print("property editor specs ok") return 0 if __name__ == "__main__": raise SystemExit(main())