feat: 完善 Face 参数化编辑和隔离执行

This commit is contained in:
2026-07-31 16:36:05 +08:00
parent 27e4f7236c
commit bb44e3920d
31 changed files with 5428 additions and 252 deletions
+77
View File
@@ -0,0 +1,77 @@
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_all
project_root = os.path.abspath(os.path.join(SPECPATH, '..'))
datas = [(os.path.join(project_root, 'assets'), 'assets')]
binaries = []
hiddenimports = [
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
'PySide6.QtOpenGL',
'PySide6.QtOpenGLWidgets',
'vtkmodules.qt.QVTKRenderWindowInteractor',
]
tmp_ret = collect_all('OCC')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('vtkmodules')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
[os.path.join(project_root, 'main.py')],
pathex=[project_root],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
'PySide6.QtWebEngineCore',
'PySide6.QtWebEngineWidgets',
'PySide6.QtWebEngineQuick',
'PySide6.QtQuick',
'PySide6.QtQml',
'PySide6.Qt3DAnimation',
'PySide6.Qt3DCore',
'PySide6.Qt3DExtras',
'PySide6.Qt3DInput',
'PySide6.Qt3DLogic',
'PySide6.Qt3DRender',
'PySide6.QtMultimedia',
'PySide6.QtMultimediaWidgets',
],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='main',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='GeometryParametric',
)
+125
View File
@@ -0,0 +1,125 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
Set-Location $projectRoot
function Assert-InProject {
param([string]$Path)
$resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
return
}
$rootText = $projectRoot.TrimEnd("\")
$pathText = $resolved.Path.TrimEnd("\")
if (-not $pathText.StartsWith($rootText, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove path outside project: $pathText"
}
}
function Invoke-Checked {
param(
[string]$FilePath,
[string[]]$Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')"
}
}
function Test-PackagingPython {
param([string]$Candidate)
if ($Candidate -ne "python" -and -not (Test-Path -LiteralPath $Candidate)) {
return $false
}
& $Candidate -c "import OCC; import PyInstaller" *> $null
return $LASTEXITCODE -eq 0
}
$pythonCandidates = @()
if ($env:CONDA_DEFAULT_ENV -eq "pyocc" -and $env:CONDA_PREFIX) {
$pythonCandidates += (Join-Path $env:CONDA_PREFIX "python.exe")
}
if ($env:USERPROFILE) {
$pythonCandidates += (Join-Path $env:USERPROFILE "miniforge3\envs\pyocc\python.exe")
$pythonCandidates += (Join-Path $env:USERPROFILE "miniconda3\envs\pyocc\python.exe")
$pythonCandidates += (Join-Path $env:USERPROFILE "anaconda3\envs\pyocc\python.exe")
}
$pythonCandidates += "python"
$python = $null
foreach ($candidate in $pythonCandidates) {
if (Test-PackagingPython $candidate) {
$python = $candidate
break
}
}
if (-not $python) {
throw "Could not find a Python environment with OCC and PyInstaller. Activate pyocc or install PyInstaller into pyocc."
}
Write-Host "Using Python: $python"
Write-Host "Running smoke test with source..."
Invoke-Checked $python @("main.py", "--smoke-test")
$buildDir = Join-Path $projectRoot "build"
$appDir = Join-Path $projectRoot "dist\GeometryParametric"
$zipPath = Join-Path $projectRoot "dist\GeometryParametric_windows_x64.zip"
$specPath = Join-Path $scriptDir "GeometryParametric.spec"
foreach ($target in @($buildDir, $appDir, $zipPath)) {
Assert-InProject $target
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
}
Write-Host "Building PyInstaller folder..."
Invoke-Checked $python @("-m", "PyInstaller", "--clean", "--noconfirm", $specPath)
if (-not (Test-Path -LiteralPath $appDir)) {
throw "PyInstaller did not create $appDir"
}
$guideName = (-join @(
[char]0x51E0,
[char]0x4F55,
[char]0x53C2,
[char]0x6570,
[char]0x5316,
[char]0x6D4B,
[char]0x8BD5,
[char]0x8BF4,
[char]0x660E
)) + ".txt"
$docSource = Join-Path (Join-Path $projectRoot "local") $guideName
if (Test-Path -LiteralPath $docSource) {
Copy-Item -LiteralPath $docSource -Destination (Join-Path $appDir $guideName) -Force
} else {
Write-Warning "Local test document not found: $docSource"
}
$modelSourceDir = Join-Path $projectRoot "assets\models"
$modelDestDir = Join-Path $appDir "model"
New-Item -ItemType Directory -Path $modelDestDir -Force | Out-Null
$modelFiles = @()
foreach ($pattern in @("*.step", "*.stp")) {
$modelFiles += Get-ChildItem -LiteralPath $modelSourceDir -Filter $pattern -File -ErrorAction SilentlyContinue
}
if ($modelFiles.Count -eq 0) {
throw "No STEP model files found in $modelSourceDir"
}
foreach ($file in $modelFiles) {
Copy-Item -LiteralPath $file.FullName -Destination $modelDestDir -Force
}
Write-Host "Running smoke test with packaged exe..."
Invoke-Checked (Join-Path $appDir "main.exe") @("--smoke-test")
Write-Host "Creating zip in dist..."
Compress-Archive -Path $appDir -DestinationPath $zipPath -Force
Write-Host "Package ready:"
Write-Host $zipPath
+86 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import math
from pathlib import Path
import sys
import tempfile
@@ -52,6 +53,32 @@ def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int,
return best[0], best[1]
def _nearest_cone_semi_angle(model: StepModel, target_angle_degrees: float) -> tuple[int, float]:
best: tuple[int, float, float] | None = None
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cone":
continue
angle = info.get("semi_angle")
if not isinstance(angle, (int, float)):
continue
angle_degrees = abs(math.degrees(float(angle)))
score = abs(angle_degrees - target_angle_degrees)
if best is None or score < best[2]:
best = (face_id, angle_degrees, score)
if best is None:
diameters = _thin_cap_diameters(model)
if len(diameters) >= 2:
small = min(diameters)
large = max(diameters)
height = _bbox_z_size(model)
if height > 1e-9 and large > small:
angle_degrees = math.degrees(math.atan(((large - small) * 0.5) / height))
return -1, angle_degrees
raise SystemExit("no cone Face remained after edit and cap diameters could not recover the semi-angle")
return best[0], best[1]
def _nearest_torus_radii(
model: StepModel,
target_major: float,
@@ -73,17 +100,22 @@ def _nearest_torus_radii(
def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool:
return any(abs(diameter - target_diameter) <= tolerance for diameter in _thin_cap_diameters(model))
def _thin_cap_diameters(model: StepModel) -> list[float]:
diameters: list[float] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
size = info.get("bbox_size")
if not isinstance(size, tuple) or len(size) != 3:
continue
dx, dy, dz = (float(size[0]), float(size[1]), float(size[2]))
if dz > max(tolerance * 10.0, 1e-5):
if dz > 1e-4:
continue
if abs(dx - target_diameter) <= tolerance and abs(dy - target_diameter) <= tolerance:
return True
return False
if abs(dx - dy) <= max(max(abs(dx), abs(dy)) * 1e-5, 1e-5):
diameters.append((dx + dy) * 0.5)
return diameters
def _bbox_z_size(model: StepModel) -> float:
@@ -128,6 +160,51 @@ def _run_cone_case(target_reference_radius: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_cone_angle_case(target_angle_degrees: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cone_angle_") as temp_dir:
model_path = Path(temp_dir) / "cone.step"
_write_cone_model(model_path)
model = StepModel.load(model_path)
face_id = _first_face_by_surface(model, "cone")
before = model.stats()
info = model.face_info(face_id)
current_reference_radius = float(info.get("reference_radius") or 0.0)
current_semi_angle = abs(float(info.get("semi_angle") or 0.0))
current_top_radius = 2.0
scale = math.tan(math.radians(target_angle_degrees)) / math.tan(current_semi_angle)
target_reference_radius = current_reference_radius * scale
target_top_radius = current_top_radius * scale
plan = model.conical_semi_angle_plan(face_id, target_angle_degrees)
if plan["status"] == "blocked":
raise SystemExit(f"cone angle plan was blocked: {plan['message']}")
result = model.resize_conical_semi_angle(face_id, target_angle_degrees)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"cone angle resize changed solid count: before={before.solids}, after={after.solids}")
if abs(_bbox_z_size(model) - 10.0) > tolerance:
raise SystemExit(f"cone angle resize changed height unexpectedly: z_size={_bbox_z_size(model):g}")
if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance):
raise SystemExit(f"cone angle bottom cap diameter was not resized to {target_reference_radius * 2.0:g}")
if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance):
raise SystemExit(f"cone angle top cap diameter was not resized to {target_top_radius * 2.0:g}")
verified_face, angle_degrees = _nearest_cone_semi_angle(model, target_angle_degrees)
if abs(angle_degrees - target_angle_degrees) > tolerance:
raise SystemExit(
f"cone semi-angle verification failed: target={target_angle_degrees:g}, value={angle_degrees:g}"
)
print("mode=cone_semi_angle")
print(f"face_id={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_semi_angle_degrees={math.degrees(current_semi_angle):.6f}")
print(f"target_semi_angle_degrees={target_angle_degrees:.6f} verified={angle_degrees:.6f}")
print(f"target_reference_radius={target_reference_radius:.6f}")
print(f"target_top_radius={target_top_radius:.6f}")
print(f"verified_face={verified_face}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_sphere_case(target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir:
model_path = Path(temp_dir) / "sphere.step"
@@ -199,19 +276,22 @@ def main() -> int:
parser.add_argument(
"--mode",
default="all",
choices=["all", "cone", "sphere", "torus_major", "torus_minor"],
choices=["all", "cone", "cone_angle", "sphere", "torus_major", "torus_minor"],
)
parser.add_argument("--cone-reference-radius", type=float, default=5.0)
parser.add_argument("--cone-semi-angle-degrees", type=float, default=16.0)
parser.add_argument("--sphere-radius", type=float, default=6.25)
parser.add_argument("--torus-major-radius", type=float, default=10.0)
parser.add_argument("--torus-minor-radius", type=float, default=3.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
modes = ["cone", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode]
modes = ["cone", "cone_angle", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode]
for mode in modes:
if mode == "cone":
_run_cone_case(args.cone_reference_radius, args.tolerance)
elif mode == "cone_angle":
_run_cone_angle_case(args.cone_semi_angle_degrees, args.tolerance)
elif mode == "sphere":
_run_sphere_case(args.sphere_radius, args.tolerance)
elif mode == "torus_major":
+65 -1
View File
@@ -58,6 +58,17 @@ def _first_boss_face(model: StepModel) -> int:
return candidates[0]
def _first_circle_edge_adjacent_to_face(model: StepModel, face_id: int) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
adjacent_face_ids = tuple(int(item) for item in info.get("adjacent_face_ids", ()) or ())
if face_id in adjacent_face_ids:
return edge_id
raise SystemExit(f"no circular edge adjacent to Face {face_id} was recognized")
def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id)
axis_point = info.get("axis_point")
@@ -185,6 +196,55 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_boss_circle_edge_axis_") as temp_dir:
model_path = Path(temp_dir) / "boss.step"
_write_boss_model(model_path)
model = StepModel.load(model_path)
face_id = _first_boss_face(model)
edge_id = _first_circle_edge_adjacent_to_face(model, face_id)
before = model.stats()
current_center = _axis_center(model, face_id)
current_diameter = _diameter(model, face_id)
current_height = _height(model, face_id)
edge_info = model.edge_info(edge_id)
edge_center = edge_info.get("center")
if not isinstance(edge_center, tuple):
raise SystemExit(f"circle edge center is missing on Edge {edge_id}")
target_edge_center = (float(edge_center[0]) + offset, float(edge_center[1]), float(edge_center[2]))
target_axis_center = (current_center[0] + offset, current_center[1], current_center[2])
plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center)
if plan["status"] == "blocked":
raise SystemExit(f"circle_edge_axis_center plan was blocked: {plan['message']}")
result = model.move_circular_edge_axis_center(edge_id, target_edge_center)
after = model.stats()
verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter, target_axis_center)
center_error = _distance(center, target_axis_center)
height_error = abs(height - current_height)
if after.solids != before.solids:
raise SystemExit(f"circle_edge_axis_center changed solid count: before={before.solids}, after={after.solids}")
if center_error > tolerance or abs(diameter - current_diameter) > tolerance:
raise SystemExit(
f"circle_edge_axis_center verification failed: target={target_axis_center}, center={center}, "
f"center_error={center_error:g}, diameter={diameter:g}"
)
if height_error > tolerance:
raise SystemExit(
f"circle_edge_axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}"
)
print("mode=circle_edge_axis_center")
print(f"source_edge={edge_id}")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"delegated_mode={plan.get('circular_edge_cylinder_mode')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target_edge_center={target_edge_center}")
print(f"target_axis_center={target_axis_center} value={center} diameter={diameter:.6f} height={height:.6f} error={center_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_height_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir:
model_path = Path(temp_dir) / "boss.step"
@@ -219,13 +279,14 @@ def main() -> int:
parser.add_argument(
"--mode",
default="all",
choices=["all", "diameter", "diameter_shrink", "height", "axis_center"],
choices=["all", "diameter", "diameter_shrink", "height", "axis_center", "circle_edge_axis_center"],
help="Boss edit mode to verify.",
)
parser.add_argument("--diameter", type=float, default=8.0)
parser.add_argument("--diameter-shrink", type=float, default=4.0)
parser.add_argument("--height", type=float, default=7.0)
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
parser.add_argument("--circle-edge-axis-center", type=float, default=2.0, help="Circle Edge center X offset to verify.")
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
@@ -235,6 +296,7 @@ def main() -> int:
("diameter_shrink", args.diameter_shrink),
("height", args.height),
("axis_center", args.axis_center),
("circle_edge_axis_center", args.circle_edge_axis_center),
]
if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
@@ -246,6 +308,8 @@ def main() -> int:
_run_height_case(float(target), args.tolerance)
elif mode == "axis_center":
_run_axis_center_case(float(target), args.tolerance)
elif mode == "circle_edge_axis_center":
_run_circle_edge_axis_center_case(float(target), args.tolerance)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge length auto strategy resolves to local deformation",
(
"verify_edge_length_resize.py",
"--strategy",
"auto",
"--anchor",
"keep-start",
"--expect-strategy",
"local-edge-only-deform",
),
),
(
"Edge length local deformation, fixed start point",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "keep-start"),
),
(
"Edge length local deformation, fixed end point",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "keep-end"),
),
(
"Edge length local deformation, fixed center",
("verify_edge_length_resize.py", "--strategy", "local-edge-only-deform", "--anchor", "center"),
),
(
"Edge length move end plane, fixed start point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-start"),
),
(
"Edge length move end plane, fixed end point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-end"),
),
(
"Edge length scale owning object, fixed start point",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "keep-start"),
),
(
"Edge length scale owning object, fixed end point",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "keep-end"),
),
(
"Edge length scale owning object, fixed center",
("verify_edge_length_resize.py", "--strategy", "scale-owning-shape-from-edge", "--anchor", "center"),
),
(
"Edge fillet, chamfer, asymmetric chamfer, distance-angle chamfer and existing fillet resize",
("verify_edge_round_chamfer.py",),
),
(
"Ellipse Edge major and minor radius resize",
("verify_ellipse_edge_resize.py",),
),
)
def main() -> int:
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
check=True,
)
print("\nEdge edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_holed_plate(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(30.0, 20.0, 8.0).Shape()
axis = gp_Ax2(gp_Pnt(15.0, 10.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 10.0).Shape()
cut = BRepAlgoAPI_Cut(plate, cutter)
shape = _finalize_boolean_result(cut, "verify holed planar face guard cut")
_write_step(shape, path)
def _first_holed_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if bool(info.get("has_inner_boundaries")):
return face_id
raise SystemExit("no planar Face with an inner boundary was found")
def _assert_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked for a holed planar Face: {plan}")
def _assert_push_pull_keeps_single_solid(path: Path, distance: float) -> str:
model = StepModel.load(path)
face_id = _first_holed_plane_face(model)
plan = model.push_pull_plan(face_id, distance)
if plan.get("status") == "blocked":
raise SystemExit(f"holed planar Face push/pull should remain available: {plan}")
result = model.push_pull_face(face_id, distance)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"holed planar Face push/pull should keep one solid: {stats}")
return result
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_boundary_") as temp_dir:
path = Path(temp_dir) / "holed_plate.step"
_write_holed_plate(path)
model = StepModel.load(path)
face_id = _first_holed_plane_face(model)
info = model.face_info(face_id)
if int(info.get("boundary_wires") or 0) < 2:
raise SystemExit(f"Face {face_id} should report at least two boundary wires: {info}")
if bool(info.get("local_face_deform_ready", True)):
raise SystemExit(f"Face {face_id} should not allow local Face deformation: {info}")
solid_raw = info.get("solid_id")
solid_id = int(solid_raw) if solid_raw is not None else -1
if solid_id < 0 or solid_id not in model._local_face_deform_readiness_cache:
raise SystemExit("local Face deformation readiness should be cached after face_info()")
other_face_id = next(
(item for item, item_solid_id in enumerate(model.face_solid_ids) if item_solid_id == solid_id and item != face_id),
None,
)
if other_face_id is None:
raise SystemExit("holed plate should expose another Face on the same Solid")
original_vertex_reader = model._local_deform_face_vertex_points
def fail_on_cache_miss(*_args: object, **_kwargs: object) -> list[tuple[float, float, float]]:
raise AssertionError("local Face readiness cache was not reused")
model._local_deform_face_vertex_points = fail_on_cache_miss # type: ignore[method-assign]
try:
cached_readiness = model._local_face_deform_readiness(other_face_id)
finally:
model._local_deform_face_vertex_points = original_vertex_reader # type: ignore[method-assign]
if bool(cached_readiness.get("local_face_deform_ready", True)):
raise SystemExit(f"cached readiness should keep local deformation disabled: {cached_readiness}")
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} does not expose a stable center")
target_center = (float(center[0]), float(center[1]), float(center[2]) + 1.0)
area = float(info.get("area") or 0.0)
if area <= 0:
raise SystemExit(f"Face {face_id} does not expose a stable area")
width = float(info.get("local_face_width") or 0.0)
if width <= 0:
raise SystemExit(f"Face {face_id} does not expose a measured Face width")
height = float(info.get("local_face_height") or 0.0)
if height <= 0:
raise SystemExit(f"Face {face_id} does not expose a measured Face height")
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_size_local_resize_plan(face_id, width * 1.1, axis="width"), "Face width local resize")
_assert_blocked(model.face_size_local_resize_plan(face_id, height * 1.1, axis="height"), "Face height local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
outward_result = _assert_push_pull_keeps_single_solid(path, 1.0)
inward_result = _assert_push_pull_keeps_single_solid(path, -1.0)
print(
"holed planar Face local edit guard ok: "
f"face_id={face_id}, boundary_wires={info.get('boundary_wires')}, "
f"blocker={info.get('local_face_deform_blocker')}, "
f"push_pull_outward={outward_result}, push_pull_inward={inward_result}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.gp import gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_split_top_box(path: Path) -> None:
left = BRepPrimAPI_MakeBox(5.0, 10.0, 10.0).Shape()
right = BRepPrimAPI_MakeBox(gp_Pnt(5.0, 0.0, 0.0), 5.0, 10.0, 10.0).Shape()
fuse = BRepAlgoAPI_Fuse(left, right)
fuse.Build()
if not fuse.IsDone():
raise SystemExit("failed to build split-top box")
_write_step(fuse.Shape(), path)
def _top_faces(model: StepModel, z_value: float, tolerance: float) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
if abs(float(center[2]) - z_value) <= tolerance:
face_ids.append(face_id)
return face_ids
def _has_top_face(model: StepModel, z_value: float, area: float, tolerance: float) -> bool:
for face_id in _top_faces(model, z_value, tolerance):
info = model.face_info(face_id)
if abs(float(info.get("area") or 0.0) - area) <= tolerance:
return True
return False
def _assert_logical_top_region(
model: StepModel,
logical_id: int,
z_value: float,
area: float,
tolerance: float,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"logical Face {logical_id} was not retained after coplanar push/pull")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"logical Face {logical_id} did not resolve into retained matches {matches}")
info = model.face_info(resolved)
center = info.get("area_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"retained logical Face {logical_id} lacks a stable center: {info}")
if abs(float(center[2]) - z_value) > tolerance:
raise SystemExit(f"retained logical Face {logical_id} should be at z={z_value:g}, got {center}")
if abs(float(info.get("area") or 0.0) - area) > tolerance:
raise SystemExit(f"retained logical Face {logical_id} should have area {area:g}, got {info.get('area')}")
return resolved
def main() -> int:
parser = argparse.ArgumentParser(description="Verify coplanar split Face push/pull as one plane region.")
parser.add_argument("--distance", type=float, default=1.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="geom_param_face_coplanar_") as temp_dir:
model_path = Path(temp_dir) / "split_top_box.step"
_write_split_top_box(model_path)
model = StepModel.load(model_path)
before = model.stats()
top_faces = _top_faces(model, 10.0, args.tolerance)
if len(top_faces) != 2:
raise SystemExit(f"expected two split top faces before push/pull, got {top_faces}")
face_id = top_faces[0]
logical_id = model.face_region_logical_id(face_id)
plan = model.push_pull_plan(face_id, args.distance)
scope_ids = tuple(plan.get("push_pull_scope_face_ids", ()))
if set(scope_ids) != set(top_faces):
raise SystemExit(f"expected push/pull scope {top_faces}, got {scope_ids}")
if plan["status"] == "blocked":
raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}")
result = model.push_pull_face(face_id, args.distance)
after = model.stats()
if after.solids != before.solids:
raise SystemExit(f"solid count changed: before={before.solids}, after={after.solids}")
if not _has_top_face(model, 10.0 + args.distance, 100.0, args.tolerance):
raise SystemExit("pushed coplanar top region was not rebuilt as a 100 mm^2 top plane")
logical_face_id = _assert_logical_top_region(
model,
logical_id,
10.0 + args.distance,
100.0,
args.tolerance,
)
print(f"model={model_path}")
print(f"source_face={face_id}")
print(f"source_logical_face={logical_id}")
print(f"retained_logical_face={logical_face_id}")
print(f"scope_faces={scope_ids}")
print(f"before={before}")
print(f"after={after}")
print(f"distance={args.distance:.6f}")
print(f"strategy={plan.get('resize_strategy', 'push-pull-planar-face')}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"face width, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
),
(
"face width, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"),
),
(
"face height, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "height", "--target-size", "15"),
),
(
"face height, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "height", "--target-size", "15"),
),
(
"rectangular face width and height axes stay separate",
("verify_face_rectangular_axes.py",),
),
(
"face area, current face only",
("verify_face_resize_semantics.py", "--property", "area", "--strategy", "local", "--target-area", "144"),
),
(
"face area, owning feature",
("verify_face_resize_semantics.py", "--property", "area", "--strategy", "owning", "--target-area", "144"),
),
(
"face center, current face only",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "local", "--center-offset", "2,0,3"),
),
(
"face center, owning feature",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "owning", "--center-offset", "2,0,3"),
),
(
"face offset, push pull",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "1"),
),
(
"face offset, push pull inward cut",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "-1"),
),
(
"face offset, inward cut limits",
("verify_face_push_pull_limits.py",),
),
(
"face offset, current face only",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "local", "--offset-distance", "1"),
),
(
"face offset, owning feature",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "owning", "--offset-distance", "1"),
),
(
"coplanar split faces, push pull as one region",
("verify_face_coplanar_push_pull.py",),
),
(
"coplanar split faces, inward cut as one region",
("verify_face_coplanar_push_pull.py", "--distance", "-1"),
),
(
"holed planar Face disables local-only deformation",
("verify_face_complex_boundary_guard.py",),
),
(
"planar Face on curved Solid disables local-only deformation",
("verify_face_mixed_surface_guard.py",),
),
(
"non-rectangular planar Face local edits",
("verify_face_nonrectangular_local_edit.py",),
),
(
"thin wall thickness, current face and owning feature",
("verify_shell_thickness_resize.py",),
),
(
"thin wall thickness decrease, current face and owning feature",
("verify_shell_thickness_resize.py", "--target-thickness", "1"),
),
(
"property editor keeps generic Face edits out of cylindrical features",
("verify_property_editor_specs.py",),
),
(
"Face edited values read back into property rows",
("verify_face_property_readback.py",),
),
(
"Face logical selection stays on the edited face",
("verify_face_logical_selection_retention.py",),
),
(
"Face invalid positive targets are blocked",
("verify_face_invalid_target_guards.py",),
),
(
"Face extreme positive targets are blocked",
("verify_face_extreme_target_guards.py",),
),
(
"Face no-op plans are blocked",
("verify_face_noop_guards.py",),
),
)
def main() -> int:
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
check=True,
)
print("\nFace edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_extreme_blocked(label: str, plan: dict[str, object], expected_scale: float) -> None:
_assert_blocked(label, plan)
scale = (
plan.get("local_face_area_scale")
or plan.get("face_size_scale")
or plan.get("affine_scale")
)
if scale is None:
raise SystemExit(f"{label} plan did not expose the resulting scale: {plan}")
if abs(float(scale) - expected_scale) > max(abs(expected_scale) * 1e-6, 1e-9):
raise SystemExit(f"{label} scale mismatch: got={scale}, expected={expected_scale}")
def _assert_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} extreme target was not blocked: status={status}, message={message}")
def _run_cube_face_extreme_targets() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
info = model.face_info(face_id)
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
if area <= 0 or width <= 0 or height <= 0:
raise SystemExit(f"selected Face is missing stable size values: area={area}, width={width}, height={height}")
current_center = info.get("area_center") or info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face is missing a stable center")
_assert_extreme_blocked(
"Face area current face, too small",
model.face_area_local_resize_plan(face_id, area * 0.0001),
0.01,
)
_assert_extreme_blocked(
"Face area current face, too large",
model.face_area_local_resize_plan(face_id, area * 100.0),
10.0,
)
_assert_extreme_blocked(
"Face area owning feature, too small",
model.face_area_scale_plan(face_id, area * 0.0001),
0.01,
)
_assert_extreme_blocked(
"Face area owning feature, too large",
model.face_area_scale_plan(face_id, area * 100.0),
10.0,
)
for axis, current in (("width", width), ("height", height)):
_assert_extreme_blocked(
f"Face {axis} current face, too small",
model.face_size_local_resize_plan(face_id, current * 0.01, axis),
0.01,
)
_assert_extreme_blocked(
f"Face {axis} current face, too large",
model.face_size_local_resize_plan(face_id, current * 10.0, axis),
10.0,
)
_assert_extreme_blocked(
f"Face {axis} owning feature, too small",
model.face_size_owning_scale_plan(face_id, current * 0.01, axis),
0.01,
)
_assert_extreme_blocked(
f"Face {axis} owning feature, too large",
model.face_size_owning_scale_plan(face_id, current * 10.0, axis),
10.0,
)
far_center = (float(current_center[0]) + 500.0, float(current_center[1]), float(current_center[2]))
local_center_plan = model.face_center_local_move_plan(face_id, far_center)
_assert_blocked("Face center current face, too far", local_center_plan)
if float(local_center_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face center current face ratio did not exceed the guard: {local_center_plan}")
owning_center_plan = model.face_center_owning_translation_plan(face_id, far_center)
_assert_blocked("Face center owning feature, too far", owning_center_plan)
if float(owning_center_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face center owning feature ratio did not exceed the guard: {owning_center_plan}")
offset_distance = 500.0
local_offset_plan = model.face_plane_offset_local_plan(face_id, offset_distance)
_assert_blocked("Face offset current face, too large", local_offset_plan)
if float(local_offset_plan.get("face_center_move_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face offset current face ratio did not exceed the guard: {local_offset_plan}")
push_pull_plan = model.push_pull_plan(face_id, offset_distance)
_assert_blocked("Face offset push/pull, too large", push_pull_plan)
push_pull_ratio = offset_distance / max(float(push_pull_plan.get("bbox_diagonal") or 0.0), 1e-9)
if push_pull_ratio <= 5.0:
raise SystemExit(f"Face push/pull ratio did not exceed the guard: {push_pull_plan}")
owning_offset_plan = model.face_plane_offset_owning_translation_plan(face_id, offset_distance)
_assert_blocked("Face offset owning feature, too large", owning_offset_plan)
if float(owning_offset_plan.get("face_offset_distance_ratio") or 0.0) <= 5.0:
raise SystemExit(f"Face offset owning feature ratio did not exceed the guard: {owning_offset_plan}")
def _run_shell_extreme_targets() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_extreme_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
current = float(model.feature_info(face_id).get("shell_thickness_estimate") or 0.0)
if current <= 0:
raise SystemExit("shell thickness candidate is missing a positive thickness")
for target, scale in ((current * 0.01, 0.01), (current * 10.0, 10.0)):
_assert_blocked(
f"thin wall current face target scale={scale:g}",
model.shell_thickness_plan(face_id, target),
)
_assert_extreme_blocked(
f"thin wall owning feature target scale={scale:g}",
model.shell_thickness_owning_scale_plan(face_id, target),
scale,
)
def main() -> int:
_run_cube_face_extreme_targets()
_run_shell_extreme_targets()
print("Face extreme target guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_positive_target_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} invalid target was not blocked: status={status}, message={message}")
if "大于 0" not in message and "大于0" not in message:
raise SystemExit(f"{label} invalid target should explain the positive range: {message}")
def _assert_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message") or plan.get("blockers") or "")
if status != "blocked":
raise SystemExit(f"{label} invalid target was not blocked: status={status}, message={message}")
def _run_cube_invalid_targets() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
for target in (0.0, -1.0):
_assert_positive_target_blocked(
f"Face area current face target={target:g}",
model.face_area_local_resize_plan(face_id, target),
)
_assert_positive_target_blocked(
f"Face area owning feature target={target:g}",
model.face_area_scale_plan(face_id, target),
)
for axis in ("width", "height"):
_assert_positive_target_blocked(
f"Face {axis} current face target={target:g}",
model.face_size_local_resize_plan(face_id, target, axis),
)
_assert_positive_target_blocked(
f"Face {axis} owning feature target={target:g}",
model.face_size_owning_scale_plan(face_id, target, axis),
)
_assert_blocked("Face area current face non-numeric target", model.face_area_local_resize_plan(face_id, "abc"))
_assert_blocked("Face area owning feature non-numeric target", model.face_area_scale_plan(face_id, "abc"))
for axis in ("width", "height"):
_assert_blocked(
f"Face {axis} current face non-numeric target",
model.face_size_local_resize_plan(face_id, "abc", axis),
)
_assert_blocked(
f"Face {axis} owning feature non-numeric target",
model.face_size_owning_scale_plan(face_id, "abc", axis),
)
def _run_shell_invalid_targets() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_invalid_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
for target in (0.0, -1.0):
_assert_positive_target_blocked(
f"thin wall current face target={target:g}",
model.shell_thickness_plan(face_id, target),
)
_assert_positive_target_blocked(
f"thin wall owning feature target={target:g}",
model.shell_thickness_owning_scale_plan(face_id, target),
)
_assert_blocked("thin wall current face non-numeric target", model.shell_thickness_plan(face_id, "abc"))
_assert_blocked(
"thin wall owning feature non-numeric target",
model.shell_thickness_owning_scale_plan(face_id, "abc"),
)
def main() -> int:
_run_cube_invalid_targets()
_run_shell_invalid_targets()
print("Face invalid target guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,165 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
TOLERANCE = 1e-5
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
metrics.append(
(
face_id,
float(info.get("area") or 0.0),
float(info.get("local_face_width") or 0.0),
float(info.get("local_face_height") or 0.0),
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _first_plane_face_near_size(model: StepModel, width: float, height: float) -> int:
for face_id, _area, face_width, face_height, _center in _plane_metrics(model):
if abs(face_width - width) <= TOLERANCE and abs(face_height - height) <= TOLERANCE:
return face_id
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _center(info: dict[str, object]) -> tuple[float, float, float]:
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face lacks a stable center: {info}")
return float(center[0]), float(center[1]), float(center[2])
def _assert_logical_face(
model: StepModel,
logical_id: int,
label: str,
*,
area: float | None = None,
width: float | None = None,
height: float | None = None,
center: tuple[float, float, float] | None = None,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"{label}: logical Face {logical_id} was not retained")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"{label}: logical Face {logical_id} did not resolve into retained matches {matches}")
info = model.face_info(resolved)
if info.get("surface") != "plane":
raise SystemExit(f"{label}: retained logical Face should be planar, got {info.get('surface')}")
if area is not None and abs(float(info.get("area") or 0.0) - area) > TOLERANCE:
raise SystemExit(f"{label}: retained Face area should be {area:g}, got {info.get('area')}")
if width is not None and abs(float(info.get("local_face_width") or 0.0) - width) > TOLERANCE:
raise SystemExit(f"{label}: retained Face width should be {width:g}, got {info.get('local_face_width')}")
if height is not None and abs(float(info.get("local_face_height") or 0.0) - height) > TOLERANCE:
raise SystemExit(f"{label}: retained Face height should be {height:g}, got {info.get('local_face_height')}")
if center is not None and _distance(_center(info), center) > TOLERANCE:
raise SystemExit(f"{label}: retained Face center should be {center}, got {_center(info)}")
return resolved
def _fresh_selected_face() -> tuple[StepModel, int, int, dict[str, object]]:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
return model, face_id, model.face_region_logical_id(face_id), model.face_info(face_id)
def _target_center(info: dict[str, object], offset: tuple[float, float, float]) -> tuple[float, float, float]:
center = _center(info)
return center[0] + offset[0], center[1] + offset[1], center[2] + offset[2]
def _offset_target_center(model: StepModel, face_id: int, info: dict[str, object], distance: float) -> tuple[float, float, float]:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face lacks a stable plane offset frame")
_origin, direction, _position = frame
center = _center(info)
return (
center[0] + direction[0] * distance,
center[1] + direction[1] * distance,
center[2] + direction[2] * distance,
)
def main() -> int:
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_size_local(face_id, 15.0, "width")
resolved = _assert_logical_face(model, logical_id, "local Face width", width=15.0, height=10.0)
print(f"local width logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_size_owning_scale(face_id, 15.0, "width")
resolved = _assert_logical_face(model, logical_id, "owning Face width", width=15.0, height=10.0)
print(f"owning width logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_area_local(face_id, 144.0)
resolved = _assert_logical_face(model, logical_id, "local Face area", area=144.0)
print(f"local area logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, _info = _fresh_selected_face()
model.resize_face_area(face_id, 144.0)
resolved = _assert_logical_face(model, logical_id, "owning Face area", area=144.0)
print(f"owning area logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, info = _fresh_selected_face()
center = _target_center(info, (2.0, 0.0, 3.0))
model.move_face_center_local(face_id, center)
resolved = _assert_logical_face(model, logical_id, "local Face center", center=center)
print(f"local center logical {logical_id} -> Face {resolved}")
model, face_id, logical_id, info = _fresh_selected_face()
center = _target_center(info, (2.0, 0.0, 3.0))
model.move_face_center_owning(face_id, center)
resolved = _assert_logical_face(model, logical_id, "owning Face center", center=center)
print(f"owning center logical {logical_id} -> Face {resolved}")
for label, operation, distance in (
("local Face plane offset", "local", 1.0),
("owning Face plane offset", "owning", 1.0),
("push/pull Face offset", "push_pull", 1.0),
("push/pull inward Face offset", "push_pull", -1.0),
):
model, face_id, logical_id, info = _fresh_selected_face()
target_center = _offset_target_center(model, face_id, info, distance)
if operation == "local":
model.move_face_plane_offset_local(face_id, distance)
elif operation == "owning":
model.translate_face_plane_offset_owning(face_id, distance)
else:
model.push_pull_face(face_id, distance)
resolved = _assert_logical_face(model, logical_id, label, center=target_center)
print(f"{label} logical {logical_id} -> Face {resolved}")
print("Face logical selection retention ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_state import WindowStateMixin
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _write_cylinder(path: Path) -> None:
cylinder = BRepPrimAPI_MakeCylinder(4.0, 8.0).Shape()
_write_step(cylinder, path)
def _first_planar_cap_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no planar cylinder cap Face was found")
def _assert_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked for a planar Face on a curved Solid: {plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if "曲面" not in message:
raise SystemExit(f"{label} should explain that the owning Solid contains curved faces: {plan}")
def _specs(face_id: int, info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
return specs
def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]:
for item in specs:
if item.get("key") == key:
return item
raise SystemExit(f"{key} spec was not found")
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
spec = _spec(specs, key)
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
def _assert_local_scope_explains_curved_owner(specs: list[dict[str, object]], key: str) -> None:
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key}/local should be disabled for a planar Face on a curved Solid")
disabled_tip = str(local_mode.get("disabled_tip") or "")
if "曲面" not in disabled_tip:
raise SystemExit(f"{key}/local disabled tip should mention curved owner: {disabled_tip}")
def _bbox_height(model: StepModel) -> float:
info = model.part_info(1)
bbox_size = info.get("bbox_size")
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
raise SystemExit(f"part bbox_size is missing: {info}")
return float(bbox_size[2])
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_mixed_surface_") as temp_dir:
path = Path(temp_dir) / "cylinder.step"
_write_cylinder(path)
model = StepModel.load(path)
face_id = _first_planar_cap_face(model)
info = model.face_info(face_id)
if bool(info.get("local_face_deform_ready", True)):
raise SystemExit(f"planar cylinder cap should not allow local Face deformation: {info}")
blocker = str(info.get("local_face_deform_blocker") or "")
if "曲面" not in blocker:
raise SystemExit(f"planar cylinder cap blocker should mention curved owner: {info}")
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} does not expose a stable center")
target_center = (float(center[0]), float(center[1]), float(center[2]) + 1.0)
area = float(info.get("area") or 0.0)
if area <= 0:
raise SystemExit(f"Face {face_id} does not expose a stable area")
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
specs = _specs(face_id, info)
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 "曲面" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the curved-owner blocker: {semantics}")
for key in ("area", "face_center_position", "face_target_normal_position"):
_assert_local_scope_explains_curved_owner(specs, key)
push_pull_mode = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(push_pull_mode.get("enabled", False)):
raise SystemExit("planar cylinder cap push/pull scope should remain available")
before_height = _bbox_height(model)
push_plan = model.push_pull_plan(face_id, 1.0)
if push_plan.get("status") == "blocked":
raise SystemExit(f"planar cylinder cap push/pull should remain available: {push_plan}")
push_result = model.push_pull_face(face_id, 1.0)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"planar cylinder cap push/pull should keep one solid: {stats}")
after_height = _bbox_height(model)
if after_height <= before_height:
raise SystemExit(f"planar cylinder cap push/pull should increase bbox height: {before_height} -> {after_height}")
print(
"mixed-surface planar Face guard ok: "
f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, "
f"push_pull={push_result}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,224 @@
from __future__ import annotations
import math
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCC.Core.gp import gp_Pnt, gp_Vec
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_triangular_prism(path: Path) -> None:
polygon = BRepBuilderAPI_MakePolygon()
polygon.Add(gp_Pnt(0.0, 0.0, 0.0))
polygon.Add(gp_Pnt(12.0, 0.0, 0.0))
polygon.Add(gp_Pnt(0.0, 8.0, 0.0))
polygon.Close()
if hasattr(polygon, "IsDone") and not polygon.IsDone():
raise RuntimeError("Could not create triangular prism profile.")
face_maker = BRepBuilderAPI_MakeFace(polygon.Wire())
if hasattr(face_maker, "IsDone") and not face_maker.IsDone():
raise RuntimeError("Could not create triangular prism face.")
prism = BRepPrimAPI_MakePrism(face_maker.Face(), gp_Vec(0.0, 0.0, 6.0)).Shape()
_write_step(prism, path)
def _triangle_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if int(info.get("local_face_size_source_point_count") or 0) == 3:
return face_id
raise SystemExit("no triangular planar Face was found")
def _triangle_infos(model: StepModel) -> list[dict[str, object]]:
infos: list[dict[str, object]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
if int(info.get("local_face_size_source_point_count") or 0) == 3:
infos.append(info)
return infos
def _center(info: dict[str, object]) -> tuple[float, float, float]:
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"triangular Face lacks a stable center: {info}")
return float(center[0]), float(center[1]), float(center[2])
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _nearest_center_error(model: StepModel, target: tuple[float, float, float]) -> float:
infos = _triangle_infos(model)
if not infos:
raise SystemExit("local edit removed every triangular Face")
return min(_distance(_center(info), target) for info in infos)
def _has_triangle_area(model: StepModel, target_area: float, tolerance: float = 1e-4) -> bool:
return any(abs(float(info.get("area") or 0.0) - target_area) <= tolerance for info in _triangle_infos(model))
def _has_triangle_size(
model: StepModel,
key: str,
target_size: float,
tolerance: float = 1e-4,
) -> bool:
return any(abs(float(info.get(key) or 0.0) - target_size) <= tolerance for info in _triangle_infos(model))
def _assert_not_blocked(plan: dict[str, object], label: str) -> None:
if plan.get("status") == "blocked":
raise SystemExit(f"{label} should be available for a simple triangular Face: {plan}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label} should keep one Solid, got {stats}")
def _fresh_model(path: Path) -> tuple[StepModel, int, dict[str, object]]:
model = StepModel.load(path)
face_id = _triangle_face_id(model)
return model, face_id, model.face_info(face_id)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_triangle_") as temp_dir:
path = Path(temp_dir) / "triangular_prism.step"
_write_triangular_prism(path)
model, face_id, info = _fresh_model(path)
current_center = _center(info)
target_center = (current_center[0] + 1.0, current_center[1] + 0.5, current_center[2] + 1.5)
plan = model.face_center_local_move_plan(face_id, target_center)
_assert_not_blocked(plan, "triangular Face center move")
model.move_face_center_local(face_id, target_center)
_assert_single_solid(model, "triangular Face center move")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face center move did not reach the target center")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
plan = model.face_area_local_resize_plan(face_id, target_area)
_assert_not_blocked(plan, "triangular Face area resize")
model.resize_face_area_local(face_id, target_area)
_assert_single_solid(model, "triangular Face area resize")
if not _has_triangle_area(model, target_area):
raise SystemExit("triangular Face area resize did not create the target area")
for axis, key in (("width", "local_face_width"), ("height", "local_face_height")):
model, face_id, info = _fresh_model(path)
current_size = float(info.get(key) or 0.0)
target_size = current_size * 1.25
plan = model.face_size_local_resize_plan(face_id, target_size, axis)
_assert_not_blocked(plan, f"triangular Face {axis} resize")
model.resize_face_size_local(face_id, target_size, axis)
_assert_single_solid(model, f"triangular Face {axis} resize")
if not _has_triangle_size(model, key, target_size):
raise SystemExit(f"triangular Face {axis} resize did not create the target size")
model, face_id, info = _fresh_model(path)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("triangular Face lacks a stable plane offset frame")
_origin, direction, _position = frame
current_center = _center(info)
offset_distance = 1.0
target_center = (
current_center[0] + direction[0] * offset_distance,
current_center[1] + direction[1] * offset_distance,
current_center[2] + direction[2] * offset_distance,
)
plan = model.face_plane_offset_local_plan(face_id, offset_distance)
_assert_not_blocked(plan, "triangular Face plane offset")
model.move_face_plane_offset_local(face_id, offset_distance)
_assert_single_solid(model, "triangular Face plane offset")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face plane offset did not reach the target plane")
model, face_id, _info = _fresh_model(path)
plan = model.push_pull_plan(face_id, 1.0)
_assert_not_blocked(plan, "triangular Face push/pull")
model.push_pull_face(face_id, 1.0)
_assert_single_solid(model, "triangular Face push/pull")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
plan = model.face_area_scale_plan(face_id, target_area)
_assert_not_blocked(plan, "triangular Face owning area resize")
model.resize_face_area(face_id, target_area)
_assert_single_solid(model, "triangular Face owning area resize")
if not _has_triangle_area(model, target_area):
raise SystemExit("triangular Face owning area resize did not create the target area")
for axis, key in (("width", "local_face_width"), ("height", "local_face_height")):
model, face_id, info = _fresh_model(path)
current_size = float(info.get(key) or 0.0)
target_size = current_size * 1.25
plan = model.face_size_owning_scale_plan(face_id, target_size, axis)
_assert_not_blocked(plan, f"triangular Face owning {axis} resize")
model.resize_face_size_owning_scale(face_id, target_size, axis)
_assert_single_solid(model, f"triangular Face owning {axis} resize")
if not _has_triangle_size(model, key, target_size):
raise SystemExit(f"triangular Face owning {axis} resize did not create the target size")
model, face_id, info = _fresh_model(path)
current_center = _center(info)
offset = (1.0, 0.5, 1.5)
target_center = (
current_center[0] + offset[0],
current_center[1] + offset[1],
current_center[2] + offset[2],
)
plan = model.face_center_owning_translation_plan(face_id, target_center)
_assert_not_blocked(plan, "triangular Face owning center move")
model.move_face_center_owning(face_id, target_center)
_assert_single_solid(model, "triangular Face owning center move")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face owning center move did not reach the target center")
model, face_id, info = _fresh_model(path)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("triangular Face lacks a stable plane offset frame")
_origin, direction, _position = frame
current_center = _center(info)
target_center = (
current_center[0] + direction[0],
current_center[1] + direction[1],
current_center[2] + direction[2],
)
plan = model.face_plane_offset_owning_translation_plan(face_id, 1.0)
_assert_not_blocked(plan, "triangular Face owning plane offset")
model.translate_face_plane_offset_owning(face_id, 1.0)
_assert_single_solid(model, "triangular Face owning plane offset")
if _nearest_center_error(model, target_center) > 1e-4:
raise SystemExit("triangular Face owning plane offset did not reach the target plane")
print("non-rectangular planar Face edit semantics ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_noop_blocked(label: str, plan: dict[str, object]) -> None:
status = str(plan.get("status", ""))
message = str(plan.get("message", ""))
if status != "blocked":
raise SystemExit(f"{label} no-op plan was not blocked: status={status}, message={message}")
if not any(fragment in message for fragment in ("不需要修改", "无需移动", "为 0", "几乎相同")):
raise SystemExit(f"{label} no-op blocker did not explain unchanged target: {message}")
def _run_cube_face_noops() -> None:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("plane Face center is missing")
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
_assert_noop_blocked("Face push/pull distance", model.push_pull_plan(face_id, 0.0))
_assert_noop_blocked("Face offset current face", model.face_plane_offset_local_plan(face_id, 0.0))
_assert_noop_blocked("Face offset owning feature", model.face_plane_offset_owning_translation_plan(face_id, 0.0))
_assert_noop_blocked("Face center current face", model.face_center_local_move_plan(face_id, center))
_assert_noop_blocked("Face center owning feature", model.face_center_owning_translation_plan(face_id, center))
_assert_noop_blocked("Face area current face", model.face_area_local_resize_plan(face_id, area))
_assert_noop_blocked("Face area owning feature", model.face_area_scale_plan(face_id, area))
_assert_noop_blocked("Face width current face", model.face_size_local_resize_plan(face_id, width, "width"))
_assert_noop_blocked("Face width owning feature", model.face_size_owning_scale_plan(face_id, width, "width"))
_assert_noop_blocked("Face height current face", model.face_size_local_resize_plan(face_id, height, "height"))
_assert_noop_blocked("Face height owning feature", model.face_size_owning_scale_plan(face_id, height, "height"))
def _run_shell_noops() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_face_noop_shell_") as temp_dir:
model_path = Path(temp_dir) / "plate.step"
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, 2.0, 2e-4)
current = float(model.feature_info(face_id).get("shell_thickness_estimate") or 0.0)
_assert_noop_blocked("thin wall current face", model.shell_thickness_plan(face_id, current))
_assert_noop_blocked("thin wall owning feature", model.shell_thickness_owning_scale_plan(face_id, current))
def main() -> int:
_run_cube_face_noops()
_run_shell_noops()
print("Face no-op guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.window_state import WindowStateMixin
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _specs(face_id: int, info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
return specs
def _spec(face_id: int, info: dict[str, object], key: str) -> dict[str, object]:
for spec in _specs(face_id, info):
if spec.get("key") == key:
return spec
raise SystemExit(f"{key} spec was not found for Face {face_id}")
def _float(value: object, label: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"{label} should be numeric, got {value!r}") from exc
def _assert_close(label: str, value: object, expected: float, tolerance: float = 1e-5) -> None:
number = _float(value, label)
if abs(number - expected) > tolerance:
raise SystemExit(f"{label} should be {expected:g}, got {number:g}")
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
metrics.append(
(
face_id,
float(info.get("area") or 0.0),
float(info.get("local_face_width") or 0.0),
float(info.get("local_face_height") or 0.0),
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float = 1e-5) -> int:
for face_id, _area, face_width, face_height, _center in _plane_metrics(model):
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
if direct or swapped:
return face_id
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
def _nearest_plane_center(
model: StepModel,
target: tuple[float, float, float],
) -> tuple[int, tuple[float, float, float], float]:
best: tuple[int, tuple[float, float, float], float] | None = None
for face_id, _area, _width, _height, center in _plane_metrics(model):
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error)
if best is None:
raise SystemExit("no plane Face center could be measured")
return best
def _assert_spec_readback(
model: StepModel,
face_id: int,
key: str,
expected: float,
tolerance: float = 1e-5,
) -> None:
info = model.face_info(face_id)
spec = _spec(face_id, info, key)
_assert_close(f"{key} current_raw", spec.get("current_raw"), expected, tolerance)
_assert_close(f"{key} target_text", spec.get("target_text"), expected, tolerance)
if spec.get("status_text") == "不可修改":
raise SystemExit(f"{key} should remain editable after readback: {spec}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
model.resize_face_size_local(face_id, 15.0, "width")
face_id = _first_plane_face_near_size(model, 15.0, 10.0)
_assert_spec_readback(model, face_id, "local_face_width", 15.0)
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
model.resize_face_area_local(face_id, 144.0)
for candidate_id, area, _width, _height, _center in _plane_metrics(model):
if abs(area - 144.0) <= 1e-5:
_assert_spec_readback(model, candidate_id, "area", 144.0)
break
else:
raise SystemExit("no plane Face near area 144 after local area resize")
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("selected Face lacks a stable center")
target_center = (float(center[0]) + 2.0, float(center[1]), float(center[2]) + 3.0)
model.move_face_center_local(face_id, target_center)
moved_face_id, _moved_center, center_error = _nearest_plane_center(model, target_center)
if center_error > 1e-5:
raise SystemExit(f"no plane Face near moved center {target_center}")
moved_info = model.face_info(moved_face_id)
center_spec = _spec(moved_face_id, moved_info, "face_center_position")
if str(center_spec.get("target_text") or "").replace(" ", "") != "7,5,3":
raise SystemExit(f"Face center target_text did not read back the moved center: {center_spec}")
for strategy in ("local", "owning", "push_pull"):
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face_near_size(model, 10.0, 10.0)
info = model.face_info(face_id)
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face lacks a stable plane offset frame")
_origin, direction, old_position = frame
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit("selected Face lacks a stable center")
target_center = (
float(center[0]) + direction[0],
float(center[1]) + direction[1],
float(center[2]) + direction[2],
)
if strategy == "local":
model.move_face_plane_offset_local(face_id, 1.0)
elif strategy == "owning":
model.translate_face_plane_offset_owning(face_id, 1.0)
else:
model.push_pull_face(face_id, 1.0)
moved_face_id, _moved_center, center_error = _nearest_plane_center(model, target_center)
if center_error > 1e-5:
raise SystemExit(f"no plane Face near offset target for strategy {strategy}")
moved_info = model.face_info(moved_face_id)
spec = _spec(moved_face_id, moved_info, "face_target_normal_position")
current_raw = _float(spec.get("current_raw"), f"{strategy} face_target_normal_position current_raw")
target_text = _float(spec.get("target_text"), f"{strategy} face_target_normal_position target_text")
if abs(current_raw) <= 1e-6:
raise SystemExit(f"{strategy} offset readback still looks unchanged: {spec}")
if abs(current_raw - target_text) > 1e-5:
raise SystemExit(f"{strategy} offset target_text should match refreshed current value: {spec}")
if abs(abs(current_raw) - abs(old_position + 1.0)) > 1e-5:
raise SystemExit(f"{strategy} offset readback has unexpected plane position: {spec}")
print("Face property readback ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
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
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
def _first_plane_face(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") == "plane":
return face_id
raise SystemExit("no plane Face found")
def _assert_plan(label: str, plan: dict[str, object], status: str, risk: str | None = None) -> None:
actual_status = str(plan.get("status", ""))
actual_risk = str(plan.get("risk", ""))
if actual_status != status:
raise SystemExit(f"{label} expected status={status}, got {actual_status}: {plan}")
if risk is not None and actual_risk != risk:
raise SystemExit(f"{label} expected risk={risk}, got {actual_risk}: {plan}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
face_id = _first_plane_face(model)
shallow = model.push_pull_plan(face_id, -1.0)
_assert_plan("shallow inward cut", shallow, "ready", "low")
if abs(float(shallow.get("push_pull_inward_material_depth") or 0.0) - 10.0) > 1e-5:
raise SystemExit(f"shallow cut should report 10mm inward material depth: {shallow}")
if abs(float(shallow.get("push_pull_inward_cut_ratio") or 0.0) - 0.1) > 1e-5:
raise SystemExit(f"shallow cut should report 0.1 inward cut ratio: {shallow}")
near_through = model.push_pull_plan(face_id, -9.0)
_assert_plan("near-through inward cut", near_through, "caution", "high")
if float(near_through.get("push_pull_inward_cut_ratio") or 0.0) < 0.85:
raise SystemExit(f"near-through cut should report a high inward cut ratio: {near_through}")
through = model.push_pull_plan(face_id, -10.0)
_assert_plan("through inward cut", through, "blocked", "blocked")
message = str(through.get("message") or through.get("blockers") or "")
if "材料厚度" not in message and "切空" not in message:
raise SystemExit(f"through cut blocker should explain material depth: {through}")
try:
model.push_pull_face(face_id, -10.0)
except ValueError:
pass
else:
raise SystemExit("push_pull_face should reject an inward cut that reaches full material depth")
print("Face push/pull limit guards ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
from step_editor.window_state import WindowStateMixin
SOURCE_WIDTH = 10.0
SOURCE_HEIGHT = 20.0
TARGET_WIDTH = 14.0
TARGET_HEIGHT = 26.0
TOLERANCE = 2e-4
class _PropertySpecProbe(WindowStateMixin):
def __init__(self, face_id: int, info: dict[str, object]) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "face"
self.selected_part_id = int(info.get("part_id", 1))
self.selected_solid_id = int(info.get("solid_id", 1))
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _write_rectangular_box(path: Path) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 10.0, 6.0).Shape()
_write_step(shape, path)
def _plane_infos(model: StepModel) -> list[tuple[int, float, float]]:
infos: list[tuple[int, float, float]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
infos.append((face_id, width, height))
return infos
def _face_near_size(model: StepModel, width: float, height: float) -> int:
for face_id, face_width, face_height in _plane_infos(model):
if abs(face_width - width) <= TOLERANCE and abs(face_height - height) <= TOLERANCE:
return face_id
raise SystemExit(f"no plane Face near width={width:g}, height={height:g}; got {_plane_infos(model)}")
def _assert_close(label: str, value: object, expected: float) -> None:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"{label} should be numeric, got {value!r}") from exc
if abs(number - expected) > TOLERANCE:
raise SystemExit(f"{label} should be {expected:g}, got {number:g}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label} should keep one Solid, got {stats}")
def _assert_size_specs(model: StepModel, face_id: int, width: float, height: float, label: str) -> None:
info = model.face_info(face_id)
probe = _PropertySpecProbe(face_id, info)
specs, _used = probe._editable_property_specs(info)
by_key = {str(spec.get("key")): spec for spec in specs}
width_spec = by_key.get("local_face_width")
height_spec = by_key.get("local_face_height")
if width_spec is None or height_spec is None:
raise SystemExit(f"{label} should expose width and height specs: {sorted(by_key)}")
_assert_close(f"{label} width current_raw", width_spec.get("current_raw"), width)
_assert_close(f"{label} width target_text", width_spec.get("target_text"), width)
_assert_close(f"{label} height current_raw", height_spec.get("current_raw"), height)
_assert_close(f"{label} height target_text", height_spec.get("target_text"), height)
if width_spec.get("status_text") == "不可修改" or height_spec.get("status_text") == "不可修改":
raise SystemExit(f"{label} width/height should remain editable: {width_spec} {height_spec}")
def _axis_index(direction: object) -> int:
if not isinstance(direction, tuple) or len(direction) != 3:
raise SystemExit(f"axis direction should be a vector, got {direction!r}")
values = [abs(float(direction[0])), abs(float(direction[1])), abs(float(direction[2]))]
return max(range(3), key=lambda index: values[index])
def _bbox_size(model: StepModel) -> tuple[float, float, float]:
return tuple(float(value) for value in model.geometry_stats()["bbox_size"])
def _run_local_case(path: Path, axis: str, target_size: float, expected_width: float, expected_height: float) -> None:
model = StepModel.load(path)
face_id = _face_near_size(model, SOURCE_WIDTH, SOURCE_HEIGHT)
before = model.stats()
plan = model.face_size_local_resize_plan(face_id, target_size, axis)
if plan.get("status") == "blocked":
raise SystemExit(f"local rectangular {axis} plan was blocked: {plan}")
expected_strategy = f"local-face-{axis}-only-deform"
if plan.get("resize_strategy") != expected_strategy:
raise SystemExit(f"local rectangular {axis} expected {expected_strategy}, got {plan}")
result = model.resize_face_size_local(face_id, target_size, axis)
_assert_single_solid(model, f"local rectangular {axis}")
if model.stats().faces != before.faces:
raise SystemExit(f"local rectangular {axis} should keep face count stable: before={before}, after={model.stats()}")
edited_face_id = _face_near_size(model, expected_width, expected_height)
_assert_size_specs(model, edited_face_id, expected_width, expected_height, f"local rectangular {axis}")
print(f"local {axis}: Face {face_id} -> {edited_face_id}, size={expected_width:g} x {expected_height:g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_owning_case(path: Path, axis: str, target_size: float, expected_width: float, expected_height: float) -> None:
model = StepModel.load(path)
face_id = _face_near_size(model, SOURCE_WIDTH, SOURCE_HEIGHT)
before = model.stats()
before_bbox = _bbox_size(model)
plan = model.face_size_owning_scale_plan(face_id, target_size, axis)
if plan.get("status") == "blocked":
raise SystemExit(f"owning rectangular {axis} plan was blocked: {plan}")
expected_strategy = f"axis-scale-owning-shape-from-face-{axis}"
if plan.get("resize_strategy") != expected_strategy:
raise SystemExit(f"owning rectangular {axis} expected {expected_strategy}, got {plan}")
if plan.get("owning_face_size_rebuild_mode") != "planar-rebuild":
raise SystemExit(f"simple rectangular box should use planar rebuild for owning {axis}: {plan}")
resized_axis = _axis_index(plan.get("face_size_axis_direction"))
result = model.resize_face_size_owning_scale(face_id, target_size, axis)
_assert_single_solid(model, f"owning rectangular {axis}")
if model.stats().faces != before.faces:
raise SystemExit(f"owning rectangular {axis} should keep face count stable: before={before}, after={model.stats()}")
after_bbox = _bbox_size(model)
_assert_close(f"owning rectangular {axis} bbox axis {resized_axis}", after_bbox[resized_axis], target_size)
for index, before_size in enumerate(before_bbox):
if index == resized_axis:
continue
_assert_close(f"owning rectangular {axis} unchanged bbox axis {index}", after_bbox[index], before_size)
edited_face_id = _face_near_size(model, expected_width, expected_height)
_assert_size_specs(model, edited_face_id, expected_width, expected_height, f"owning rectangular {axis}")
print(f"owning {axis}: Face {face_id} -> {edited_face_id}, size={expected_width:g} x {expected_height:g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_face_rect_axes_") as temp_dir:
path = Path(temp_dir) / "rectangular_box.step"
_write_rectangular_box(path)
_run_local_case(path, "width", TARGET_WIDTH, TARGET_WIDTH, SOURCE_HEIGHT)
_run_local_case(path, "height", TARGET_HEIGHT, SOURCE_WIDTH, TARGET_HEIGHT)
_run_owning_case(path, "width", TARGET_WIDTH, TARGET_WIDTH, SOURCE_HEIGHT)
_run_owning_case(path, "height", TARGET_HEIGHT, SOURCE_WIDTH, TARGET_HEIGHT)
print("rectangular Face width/height axis semantics ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+241 -7
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
from pathlib import Path
import subprocess
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -12,6 +13,20 @@ from step_editor.model import StepModel
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
FACE_CASES = (
("size", "local", "width"),
("size", "owning", "width"),
("size", "local", "height"),
("size", "owning", "height"),
("area", "local", "width"),
("area", "owning", "width"),
("center", "local", "width"),
("center", "owning", "width"),
("offset", "local", "width"),
("offset", "owning", "width"),
("offset", "push_pull", "width"),
)
def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int:
for face_id in range(len(model.faces)):
@@ -40,6 +55,30 @@ def _plane_sizes(model: StepModel) -> list[tuple[float, float, float]]:
return sorted(sizes)
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
metrics.append(
(
face_id,
area,
width,
height,
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool:
for _area, face_width, face_height in _plane_sizes(model):
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
@@ -49,31 +88,197 @@ def _has_plane_size(model: StepModel, width: float, height: float, tolerance: fl
return False
def _has_plane_area(model: StepModel, area: float, tolerance: float) -> bool:
return any(abs(candidate_area - area) <= tolerance for _face_id, candidate_area, _w, _h, _c in _plane_metrics(model))
def _nearest_plane_center(
model: StepModel,
target: tuple[float, float, float],
) -> tuple[int, tuple[float, float, float], float, float, float]:
best: tuple[int, tuple[float, float, float], float, float, float] | None = None
for face_id, area, width, height, center in _plane_metrics(model):
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error, width, height)
if best is None:
raise SystemExit("no plane Face center could be measured")
return best
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _parse_vector3(text: str) -> tuple[float, float, float]:
parts = [part.strip() for part in text.replace("", ",").split(",")]
if len(parts) != 3:
raise SystemExit("--center-offset must be in X,Y,Z format")
try:
return float(parts[0]), float(parts[1]), float(parts[2])
except ValueError as exc:
raise SystemExit("--center-offset must contain numbers") from exc
def _run_all_cases(args: argparse.Namespace) -> int:
script = Path(__file__).resolve()
common = [
str(script),
str(args.model),
"--source-size",
str(args.source_size),
"--other-size",
str(args.other_size),
"--target-size",
str(args.target_size),
"--target-area",
str(args.target_area),
"--center-offset",
str(args.center_offset),
"--offset-distance",
str(args.offset_distance),
"--tolerance",
str(args.tolerance),
]
for property_name, strategy, axis in FACE_CASES:
print(f"\n=== Face case: property={property_name} strategy={strategy} axis={axis} ===", flush=True)
command = [
sys.executable,
*common,
"--property",
property_name,
"--strategy",
strategy,
"--axis",
axis,
]
completed = subprocess.run(command, check=False)
if completed.returncode != 0:
return completed.returncode
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--all", action="store_true", help="Run all Face semantic edit cases on fresh model loads.")
parser.add_argument("--property", default="size", choices=["size", "area", "center", "offset"])
parser.add_argument("--axis", default="width", choices=["width", "height"])
parser.add_argument("--source-size", type=float, default=10.0)
parser.add_argument("--other-size", type=float, default=10.0)
parser.add_argument("--target-size", type=float, default=15.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning"])
parser.add_argument("--target-area", type=float, default=144.0)
parser.add_argument("--center-offset", default="2,0,3")
parser.add_argument("--offset-distance", type=float, default=1.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull"])
parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args()
if args.all:
return _run_all_cases(args)
model = StepModel.load(Path(args.model))
face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance)
before = model.stats()
current_info = model.face_info(face_id)
if args.strategy == "local":
if args.strategy == "push_pull" and args.property != "offset":
raise SystemExit("--strategy push_pull is only valid with --property offset")
if args.property == "size" and args.strategy == "local":
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
expected_strategy = f"local-face-{args.axis}-only-deform"
else:
elif args.property == "size":
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "area" and args.strategy == "local":
plan = model.face_area_local_resize_plan(face_id, args.target_area)
result = model.resize_face_area_local(face_id, args.target_area)
expected_strategy = "local-face-area-only-deform"
elif args.property == "area":
plan = model.face_area_scale_plan(face_id, args.target_area)
result = model.resize_face_area(face_id, args.target_area)
expected_strategy = "uniform-scale-face-area-fallback"
elif args.property == "center" and args.strategy == "local":
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
plan = model.face_center_local_move_plan(face_id, target_center)
result = model.move_face_center_local(face_id, target_center)
expected_strategy = "local-face-only-deform"
elif args.property == "center":
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
plan = model.face_center_owning_translation_plan(face_id, target_center)
result = model.move_face_center_owning(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
elif args.property == "offset" and args.strategy == "local":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_local_plan(face_id, args.offset_distance)
result = model.move_face_plane_offset_local(face_id, args.offset_distance)
expected_strategy = "local-face-plane-offset-deform"
elif args.property == "offset" and args.strategy == "owning":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_owning_translation_plan(face_id, args.offset_distance)
result = model.translate_face_plane_offset_owning(face_id, args.offset_distance)
expected_strategy = "translate-owning-shape-from-plane-offset"
else:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_plan(face_id, args.offset_distance)
result = model.push_pull_face(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face"
resolved_strategy = str(plan.get("resize_strategy", ""))
if args.property == "offset" and args.strategy == "push_pull":
resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
@@ -83,13 +288,40 @@ def main() -> int:
if after.faces < before.faces:
raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}")
width = args.target_size if args.axis == "width" else args.other_size
height = args.other_size if args.axis == "width" else args.target_size
if not _has_plane_size(model, width, height, args.tolerance):
raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}")
extra_lines: list[str] = []
if args.property == "size":
width = args.target_size if args.axis == "width" else args.other_size
height = args.other_size if args.axis == "width" else args.target_size
if not _has_plane_size(model, width, height, args.tolerance):
raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}")
extra_lines.append(f"target_size_pair=({width:.6f}, {height:.6f})")
elif args.property == "area":
if not _has_plane_area(model, args.target_area, args.tolerance):
raise SystemExit(f"no resulting plane Face near area {args.target_area:g}")
extra_lines.append(f"target_area={args.target_area:.6f}")
elif args.property == "center":
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
else:
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near offset target {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"offset_distance={args.offset_distance:.6f}")
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
print(f"model={Path(args.model)}")
print(f"face_id={face_id}")
print(f"property={args.property}")
print(f"strategy={args.strategy}")
print(f"axis={args.axis}")
print(f"resolved_strategy={resolved_strategy}")
@@ -97,6 +329,8 @@ def main() -> int:
print(f"before={before}")
print(f"after={after}")
print(f"plane_sizes={_plane_sizes(model)}")
for line in extra_lines:
print(line)
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
+62
View File
@@ -72,6 +72,17 @@ def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int:
return candidates[0]
def _first_circle_edge_adjacent_to_face(model: StepModel, face_id: int) -> int:
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
adjacent_face_ids = tuple(int(item) for item in info.get("adjacent_face_ids", ()) or ())
if face_id in adjacent_face_ids:
return edge_id
raise SystemExit(f"no circular edge adjacent to Face {face_id} was recognized")
def _axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id)
axis_point = info.get("axis_point")
@@ -208,6 +219,52 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None:
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_circle_edge_axis_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=False)
edge_id = _first_circle_edge_adjacent_to_face(model, face_id)
before = model.stats()
current_axis_center = _axis_center(model, face_id)
current_diameter = _diameter(model, face_id)
edge_info = model.edge_info(edge_id)
edge_center = edge_info.get("center")
if not isinstance(edge_center, tuple):
raise SystemExit(f"circle edge center is missing on Edge {edge_id}")
target_edge_center = (float(edge_center[0]) + offset, float(edge_center[1]), float(edge_center[2]))
target_axis_center = (current_axis_center[0] + offset, current_axis_center[1], current_axis_center[2])
plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center)
if plan["status"] == "blocked":
raise SystemExit(f"circle_edge_axis_center plan was blocked: {plan['message']}")
result = model.move_circular_edge_axis_center(edge_id, target_edge_center)
after = model.stats()
verified_face, diameter, center, _ = _nearest_hole_by_diameter(
model,
current_diameter,
target_axis_center,
blind=False,
)
center_error = _distance(center, target_axis_center)
if center_error > tolerance or abs(diameter - current_diameter) > tolerance:
raise SystemExit(
f"circle_edge_axis_center verification failed: target={target_axis_center}, center={center}, "
f"center_error={center_error:g}, diameter={diameter:g}"
)
print("mode=circle_edge_axis_center")
print(f"source_edge={edge_id}")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"delegated_mode={plan.get('circular_edge_cylinder_mode')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target_edge_center={target_edge_center}")
print(f"target_axis_center={target_axis_center} value={center} diameter={diameter:.6f} error={center_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_suppress_case(tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
@@ -295,6 +352,7 @@ def main() -> int:
"diameter",
"diameter_shrink",
"axis_center",
"circle_edge_axis_center",
"suppress",
"blind_depth",
"blind_depth_shallow",
@@ -304,6 +362,7 @@ def main() -> int:
parser.add_argument("--diameter", type=float, default=8.0)
parser.add_argument("--diameter-shrink", type=float, default=4.0)
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
parser.add_argument("--circle-edge-axis-center", type=float, default=2.0, help="Circle Edge center X offset to verify.")
parser.add_argument("--blind-depth", type=float, default=8.0)
parser.add_argument("--blind-depth-shallow", type=float, default=4.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
@@ -314,6 +373,7 @@ def main() -> int:
("diameter", args.diameter),
("diameter_shrink", args.diameter_shrink),
("axis_center", args.axis_center),
("circle_edge_axis_center", args.circle_edge_axis_center),
("suppress", None),
("blind_depth", args.blind_depth),
("blind_depth_shallow", args.blind_depth_shallow),
@@ -326,6 +386,8 @@ def main() -> int:
_run_diameter_case(float(target), args.tolerance)
elif mode == "axis_center":
_run_axis_center_case(float(target), args.tolerance)
elif mode == "circle_edge_axis_center":
_run_circle_edge_axis_center_case(float(target), args.tolerance)
elif mode == "suppress":
_run_suppress_case(args.tolerance)
elif mode in {"blind_depth", "blind_depth_shallow"}:
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _run_worker_case(
*,
label: str,
operation: str,
args: list[object],
validator,
input_path: Path = DEFAULT_MODEL,
):
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_face_verify_") as temp_dir:
temp_root = Path(temp_dir)
output_path = temp_root / "output.step"
request_path = temp_root / "request.json"
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"operation": operation,
"args": args,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
completed = subprocess.run(
[sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
check=False,
)
response_path = request_path.with_suffix(".response.json")
if completed.returncode != 0:
detail = response_path.read_text(encoding="utf-8") if response_path.exists() else completed.stderr
raise SystemExit(f"{label}: isolated worker failed with code {completed.returncode}: {detail}")
response = json.loads(response_path.read_text(encoding="utf-8"))
if not response.get("ok"):
raise SystemExit(f"{label}: isolated worker returned failure: {response}")
if not output_path.exists():
raise SystemExit(f"{label}: isolated worker did not produce output STEP")
model = StepModel.load(output_path)
stats = model.stats()
if stats.solids != 1:
raise SystemExit(f"{label}: isolated Face edit changed solid count unexpectedly: {stats}")
validator(label, model)
print(f"isolated Face edit ok: {label}")
print(str(response.get("message", "")).encode("ascii", "backslashreplace").decode("ascii"))
return model
def _float_close(value: object, target: float, tolerance: float = 1e-5) -> bool:
try:
return abs(float(value) - target) <= tolerance
except (TypeError, ValueError):
return False
def _triple_close(value: object, target: tuple[float, float, float], tolerance: float = 1e-5) -> bool:
if not isinstance(value, (list, tuple)) or len(value) != 3:
return False
return all(abs(float(value[index]) - target[index]) <= tolerance for index in range(3))
def _assert_plane_position(label: str, model, target_position: float = 10.0) -> None:
matches: list[tuple[int, float]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
frame = model.face_plane_offset_frame(face_id)
if frame is None:
continue
_origin, _direction, position = frame
if abs(abs(float(position)) - target_position) <= 1e-5:
matches.append((face_id, float(position)))
if not matches:
raise SystemExit(f"{label}: output does not contain a plane at target position {target_position}")
print(f"matched_planes={matches}")
def _assert_face_area(label: str, model, target_area: float = 225.0) -> None:
matches = [
(face_id, float(info.get("area")))
for face_id in range(len(model.faces))
for info in (model.face_info(face_id),)
if _float_close(info.get("area"), target_area, tolerance=1e-4)
]
if not matches:
raise SystemExit(f"{label}: output does not contain a Face with area {target_area}")
print(f"matched_areas={matches}")
def _assert_face_size_axis(label: str, model, axis_key: str, target_size: float = 25.0) -> None:
matches = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if _float_close(info.get(axis_key), target_size, tolerance=1e-4):
matches.append((face_id, axis_key, float(info[axis_key])))
if not matches:
raise SystemExit(f"{label}: output does not contain a Face {axis_key} of {target_size}")
print(f"matched_sizes={matches}")
def _assert_face_width(label: str, model, target_size: float = 25.0) -> None:
_assert_face_size_axis(label, model, "local_face_width", target_size)
def _assert_face_height(label: str, model, target_size: float = 25.0) -> None:
_assert_face_size_axis(label, model, "local_face_height", target_size)
def _assert_face_center(label: str, model, target_center: tuple[float, float, float] = (15.0, 5.0, 0.0)) -> None:
matches = [
(face_id, info.get("area_center") or info.get("bbox_center"))
for face_id in range(len(model.faces))
for info in (model.face_info(face_id),)
if _triple_close(info.get("area_center") or info.get("bbox_center"), target_center, tolerance=1e-4)
]
if not matches:
raise SystemExit(f"{label}: output does not contain a Face centered at {target_center}")
print(f"matched_centers={matches}")
def _write_shell_plate(path: Path) -> None:
_write_step(BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape(), path)
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
thickness = float(info.get("shell_thickness_estimate") or 0.0)
if abs(thickness - source_thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not candidates:
raise SystemExit(f"no shell thickness candidate near {source_thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_shell_thickness(label: str, model: StepModel, target_thickness: float = 4.0) -> None:
size = tuple(float(value) for value in model.geometry_stats()["bbox_size"])
thickness = min(size)
if abs(thickness - target_thickness) > 1e-4:
raise SystemExit(f"{label}: output thickness should be {target_thickness:g}, got {thickness:g}")
print(f"matched_shell_thickness={thickness:g}, bbox_size={size}")
def main() -> int:
_run_worker_case(
label="面偏移(当前面)",
operation="move_face_plane_offset_local",
args=[0, 10.0],
validator=_assert_plane_position,
)
_run_worker_case(
label="面积(当前面)",
operation="resize_face_area_local",
args=[0, 225.0],
validator=_assert_face_area,
)
_run_worker_case(
label="面积(整体)",
operation="resize_face_area",
args=[0, 400.0],
validator=lambda label, model: _assert_face_area(label, model, 400.0),
)
_run_worker_case(
label="面宽(当前面)",
operation="resize_face_size_local",
args=[0, 25.0, "width"],
validator=_assert_face_width,
)
_run_worker_case(
label="面高(当前面)",
operation="resize_face_size_local",
args=[0, 25.0, "height"],
validator=_assert_face_height,
)
_run_worker_case(
label="面宽(整体)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "width"],
validator=lambda label, model: _assert_face_width(label, model, 25.0),
)
_run_worker_case(
label="面高(整体)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "height"],
validator=lambda label, model: _assert_face_height(label, model, 25.0),
)
_run_worker_case(
label="中心(当前面)",
operation="move_face_center_local",
args=[0, [15.0, 5.0, 0.0]],
validator=_assert_face_center,
)
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_shell_verify_") as temp_dir:
shell_path = Path(temp_dir) / "plate.step"
_write_shell_plate(shell_path)
shell_probe = StepModel.load(shell_path)
shell_face_id = _first_shell_face(shell_probe)
shell_plan = shell_probe.shell_thickness_plan(shell_face_id, 4.0)
if str(shell_plan.get("risk")) != "high":
raise SystemExit(f"shell thickness isolation case should be high risk, got {shell_plan}")
_run_worker_case(
label="薄壁厚度(当前面)",
operation="resize_shell_thickness",
args=[shell_face_id, 4.0],
validator=_assert_shell_thickness,
input_path=shell_path,
)
shell_owning_plan = shell_probe.shell_thickness_owning_scale_plan(shell_face_id, 4.0)
if str(shell_owning_plan.get("risk")) != "high":
raise SystemExit(f"shell thickness owning isolation case should be high risk, got {shell_owning_plan}")
_run_worker_case(
label="薄壁厚度(整体)",
operation="resize_shell_thickness_owning_scale",
args=[shell_face_id, 4.0],
validator=_assert_shell_thickness,
input_path=shell_path,
)
from step_editor.window_actions import WindowActionMixin
class _IsolatedJobProbe(WindowActionMixin):
def __init__(self) -> None:
self.model = StepModel.load(DEFAULT_MODEL)
self.step_path = DEFAULT_MODEL
probe = _IsolatedJobProbe()
for title in (
"面宽(当前面)",
"面高(当前面)",
"面宽(整体)",
"面高(整体)",
"薄壁厚度(整体)缩放所属对象",
):
if not probe._quick_edit_title_supports_isolation(title):
raise SystemExit(f"{title}: quick edit title should support isolated execution")
context = {
"operation_name": "面宽(当前面)",
"target": "Face 0",
"parameters": {"part_id": 1, "face_id": 0},
"target_kind": "face",
"target_id": 0,
"target_logical_id": 0,
"pick_position": None,
"show_same_domain_internal_edges": False,
"edit_result_deflection": 2.4,
}
snapshot = probe.model.snapshot()
result = probe._run_isolated_edit_job(
context=context,
isolation={
"operation": "resize_face_size_local",
"args": [0, 25.0, "width"],
"timeout_seconds": 120.0,
},
snapshot=snapshot,
before_stats=probe.model.stats(),
before_part_stats=probe.model.part_topology_stats(1),
before_geometry={},
)
if "隔离子进程" not in str(result.get("message", "")):
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
if probe.model.stats().solids != 1:
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
print("isolated window job ok")
owning_probe = _IsolatedJobProbe()
owning_plan = owning_probe.model.face_size_owning_scale_plan(0, 25.0, "width")
owning_isolation = owning_probe._isolation_for_plan(
owning_plan,
"resize_face_size_owning_scale",
[0, 25.0, "width"],
)
if owning_isolation is None:
raise SystemExit(f"Face owning size high-risk plan should request isolated execution: {owning_plan}")
owning_result = owning_probe._run_isolated_edit_job(
context={
**context,
"operation_name": "面宽(整体)",
},
isolation=owning_isolation,
snapshot=owning_probe.model.snapshot(),
before_stats=owning_probe.model.stats(),
before_part_stats=owning_probe.model.part_topology_stats(1),
before_geometry={},
)
if "隔离子进程" not in str(owning_result.get("message", "")):
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
print("isolated owning window job ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+541
View File
@@ -0,0 +1,541 @@
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
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 _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_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 _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"(?<![端平底])面位置"),
re.compile(r"偏移距离"),
)
hits: list[str] = []
for file_path in files:
text = file_path.read_text(encoding="utf-8")
for line_number, line in enumerate(text.splitlines(), start=1):
for pattern in patterns:
if pattern.search(line):
hits.append(f"{file_path.relative_to(PROJECT_ROOT)}:{line_number}: {line.strip()}")
break
if hits:
raise SystemExit("legacy Face UI terms should not appear in user-facing sources: " + "; ".join(hits))
def _assert_target_change_detection() -> 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:
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),
}
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_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", "面宽")
_assert_label(plane_specs, "local_face_height", "面高")
_assert_label(plane_specs, "face_target_normal_position", "面偏移")
_assert_no_legacy_face_terms(plane_specs)
_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 倍", "会被阻止"),
)
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": (),
}
)
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
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),
}
)
_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",
)
boss_keys = _spec_keys(
{
"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,),
}
)
_assert_no_generic_face_leak(boss_keys, "boss feature")
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
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),
}
)
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "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:
keys = _spec_keys(info)
_assert_no_generic_face_leak(keys, label)
_assert_contains(keys, required, label)
_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())
+95
View File
@@ -52,6 +52,75 @@ def _thickness_axis(size: tuple[float, float, float]) -> int:
return min(range(3), key=lambda index: size[index])
def _face_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id)
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
raise SystemExit(f"Face {face_id} lacks a stable center")
return float(center[0]), float(center[1]), float(center[2])
def _face_area(model: StepModel, face_id: int) -> float:
return float(model.face_info(face_id).get("area") or 0.0)
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _vector(value: object, label: str) -> tuple[float, float, float]:
if not isinstance(value, tuple) or len(value) != 3:
raise SystemExit(f"{label} should be a 3D vector, got {value!r}")
return float(value[0]), float(value[1]), float(value[2])
def _axis_affine_point(
point: tuple[float, float, float],
axis_point: tuple[float, float, float],
axis_direction: tuple[float, float, float],
scale: float,
) -> tuple[float, float, float]:
length = (axis_direction[0] ** 2 + axis_direction[1] ** 2 + axis_direction[2] ** 2) ** 0.5
if length <= 1e-12:
raise SystemExit("owning shell thickness plan produced a zero axis direction")
direction = (axis_direction[0] / length, axis_direction[1] / length, axis_direction[2] / length)
relative = (point[0] - axis_point[0], point[1] - axis_point[1], point[2] - axis_point[2])
along = relative[0] * direction[0] + relative[1] * direction[1] + relative[2] * direction[2]
axial = (direction[0] * along, direction[1] * along, direction[2] * along)
rest = (relative[0] - axial[0], relative[1] - axial[1], relative[2] - axial[2])
return (
axis_point[0] + rest[0] + axial[0] * scale,
axis_point[1] + rest[1] + axial[1] * scale,
axis_point[2] + rest[2] + axial[2] * scale,
)
def _assert_logical_face_retained(
model: StepModel,
logical_id: int,
expected_center: tuple[float, float, float],
expected_area: float,
tolerance: float,
label: str,
) -> int:
matches = model.face_ids_for_logical_id(logical_id)
if not matches:
raise SystemExit(f"{label} did not retain logical Face {logical_id}")
resolved = model.resolve_face_selection_id(logical_id)
if resolved is None or resolved not in matches:
raise SystemExit(f"{label} logical Face {logical_id} did not resolve into matches {matches}")
info = model.face_info(resolved)
if info.get("surface") != "plane":
raise SystemExit(f"{label} retained logical Face should be planar, got {info.get('surface')}")
center = _face_center(model, resolved)
if _distance(center, expected_center) > tolerance:
raise SystemExit(f"{label} retained Face center should be {expected_center}, got {center}")
area = _face_area(model, resolved)
if abs(area - expected_area) > tolerance:
raise SystemExit(f"{label} retained Face area should be {expected_area:g}, got {area:g}")
return resolved
def _verify_local_bounds(
before_min: tuple[float, float, float],
before_max: tuple[float, float, float],
@@ -101,6 +170,9 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
_write_plate_model(model_path)
model = StepModel.load(model_path)
face_id = _first_shell_face(model, source_thickness, tolerance)
logical_id = model.face_region_logical_id(face_id)
before_face_center = _face_center(model, face_id)
before_face_area = _face_area(model, face_id)
before = model.stats()
before_min, before_max, before_size = _bounds(model)
axis = _thickness_axis(before_size)
@@ -109,11 +181,24 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
plan = model.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise SystemExit(f"local shell plan was blocked: {plan['message']}")
outward = _vector(plan.get("outward_direction"), "local shell outward_direction")
distance = float(plan.get("push_pull_distance", 0.0))
expected_logical_center = (
before_face_center[0] + outward[0] * distance,
before_face_center[1] + outward[1] * distance,
before_face_center[2] + outward[2] * distance,
)
result = model.resize_shell_thickness(face_id, target_thickness)
elif mode == "owning":
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise SystemExit(f"owning shell plan was blocked: {plan['message']}")
expected_logical_center = _axis_affine_point(
before_face_center,
_vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"),
_vector(plan.get("affine_axis_direction"), "owning shell affine_axis_direction"),
float(plan.get("affine_scale", 1.0)),
)
result = model.resize_shell_thickness_owning_scale(face_id, target_thickness)
else:
raise SystemExit(f"unsupported mode: {mode}")
@@ -126,9 +211,19 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
_verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
else:
_verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
retained_face_id = _assert_logical_face_retained(
model,
logical_id,
expected_logical_center,
before_face_area,
tolerance,
f"{mode} shell thickness",
)
print(f"mode={mode}")
print(f"face_id={face_id}")
print(f"logical_face_id={logical_id}")
print(f"retained_logical_face={retained_face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")