feat: 完善 Face 参数化编辑和隔离执行
This commit is contained in:
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user