Files
pythonocc-step-editor/scripts/verify_scdm_edit_runner.py
T

1109 lines
56 KiB
Python

from __future__ import annotations
import ast
import re
import subprocess
import sys
import tempfile
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.scdm_backend import ScdmBackendInfo # noqa: E402
from step_editor.scdm_edit_runner import generate_scdm_edit_script, prepare_scdm_edit_job, run_scdm_edit_job # noqa: E402
from step_editor.scdm_schema import read_json, write_json # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _fake_spaceclaim(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
return path
def _script_path_from_command(command: list[str] | tuple[str, ...]) -> Path:
for item in command:
if item.startswith("/RunScript="):
return Path(item.split("=", 1)[1])
raise AssertionError(f"missing /RunScript argument: {command}")
def _job_path_from_script(script_path: Path) -> Path:
text = script_path.read_text(encoding="utf-8")
match = re.search(r"^JOB_PATH = (.+)$", text, flags=re.MULTILINE)
_assert(match is not None, f"generated script should embed JOB_PATH: {script_path}")
return Path(ast.literal_eval(match.group(1)))
def _successful_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
output_step = Path(str(outputs["outputStep"]))
output_step.write_text("ISO-10303-21;\n/* fake SCDM output */\nEND-ISO-10303-21;\n", encoding="utf-8")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake edit finished",
"outputStep": str(output_step),
"backendOperation": job.get("target", {}).get("backendOperation"),
},
)
return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")
def _failure_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
write_json(
outputs["error"],
{
"ok": False,
"reason": "fake-failed",
"message": "fake SCDM command failed",
"backendOperation": job.get("target", {}).get("backendOperation"),
},
)
return subprocess.CompletedProcess(command, 7, stdout="", stderr="fake failure")
def _missing_output_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake success without STEP",
"outputStep": str(outputs["outputStep"]),
},
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def _empty_output_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
Path(str(outputs["outputStep"])).write_text("", encoding="utf-8")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake success with empty STEP",
"outputStep": str(outputs["outputStep"]),
},
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_edit_") as temp:
root = Path(temp)
step_path = root / "sample.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
backend = ScdmBackendInfo(path=_fake_spaceclaim(root / "SpaceClaim.exe"), source="test", version="v222")
signature = {
"objectType": "hole",
"faceIds": [85, 94],
"bodyIndex": 0,
"faceOrdinal": 12,
"faceOrdinals": [12, 19],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 12, "globalFaceOrdinal": 85},
{"bodyIndex": 0, "faceOrdinal": 19, "globalFaceOrdinal": 96},
],
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 0.0, 1.0],
}
prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "prepared",
backend=backend,
capability_key="hole.diameter",
target_value="0.75",
object_id="hole:85-94",
object_signature=signature,
timeout_seconds=45.0,
)
_assert(prepared.get("ok") is True, f"edit job should be prepared: {prepared}")
job_path = Path(str(prepared["job_path"]))
script_path = Path(str(prepared["script_path"]))
_assert(job_path.is_file(), "scdm_edit_job.json should be written")
_assert(script_path.is_file(), "scdm_edit.py should be written")
job = read_json(job_path)
_assert(job.get("adapter") == "spaceclaim-v1", f"bad adapter: {job}")
_assert(job.get("schemaVersion") == 1, f"bad schema version: {job}")
_assert(job.get("model", {}).get("sourceStep") == str(step_path.resolve(strict=False)), f"bad source path: {job}")
_assert(job.get("model", {}).get("rollbackStep") == str(step_path.resolve(strict=False)), f"bad rollback path: {job}")
_assert(job.get("target", {}).get("capabilityKey") == "hole.diameter", f"bad capability: {job}")
_assert(job.get("target", {}).get("backendOperation") == "change_hole_diameter", f"bad operation: {job}")
_assert(job.get("target", {}).get("value") == 0.75, f"target should be numeric: {job}")
_assert(job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [85, 94], f"bad signature: {job}")
_assert(job.get("execution", {}).get("isolatedProcess") is True, f"job should record isolated execution: {job}")
script = script_path.read_text(encoding="utf-8")
for token in (
"change_hole_diameter",
"move_hole_axis",
"move_slot",
"change_slot_width",
"change_slot_depth",
"move_boss",
"change_boss_height",
"change_boss_diameter",
"change_round_radius",
"change_chamfer_distance",
"change_shell_thickness",
"change_pattern_spacing",
"change_pattern_segment_spacing",
"move_pattern_instance",
"segment_before",
"segment_split",
"segment_single_left",
"segment_single_right",
"wallFaceLocators",
"pull_face_offset",
"fill_feature",
"delete_round_or_chamfer",
"StandardHoles.ModifyHoleRadius",
"ConstantRound.ModifyRadius",
"Chamfer.ModifyDistance",
"OffsetFaces.Execute",
"Move.Translate",
"MoveOptions",
"ConstantRoundOptions",
"ChamferOptions",
"OffsetFaceOptions",
"FillOptions",
"FillMode",
"Delete.Execute",
"DocumentSave",
"scdmFaceLocators",
"heightFaceLocators",
"depthFaceLocators",
"diameterFaceLocators",
"patternInstances",
"bodyLocators",
"instanceKind",
"result.json",
"error.json",
):
_assert(token in script, f"generated edit script missing {token}")
_assert("Pull.Execute" not in script, "generated edit script should use documented OffsetFaces instead of Pull.Execute")
_assert("_delete_selection(selection, fill_errors)" in script, "generated edit script should fall back from Fill to Delete")
_assert(
"if fill is None:\n raise Exception('capability_not_implemented: Fill command not available')" not in script,
"Delete-capable SCDM environments must not fail merely because Fill is missing",
)
generated = generate_scdm_edit_script(job_path)
_assert("JOB_PATH =" in generated and job_path.name in generated, "generated edit script should embed the job path")
slot_signature = {
"objectType": "slot",
"faceIds": [30, 31, 32],
"bodyIndex": 0,
"faceOrdinal": 30,
"faceOrdinals": [30, 31, 32],
"globalFaceOrdinal": 40,
"globalFaceOrdinals": [40, 41, 42],
"width": 2.0,
"depth": 1.5,
"depthAxis": [0.0, 0.0, -1.0],
"depthFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 42}],
"center": [1.0, 2.0, 3.0],
"axis": [1.0, 0.0, 0.0],
}
slot_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-position-prepared",
backend=backend,
capability_key="slot.position",
target_value=[1.0, 2.0, 5.0],
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_prepared.get("ok") is True, f"slot.position should be productized and prepare an edit job: {slot_prepared}")
slot_job = read_json(slot_prepared["job_path"])
_assert(slot_job.get("target", {}).get("backendOperation") == "move_slot", f"slot.position should route to move_slot: {slot_job}")
_assert(slot_job.get("target", {}).get("value") == [1.0, 2.0, 5.0], f"slot.position target should be vector3: {slot_job}")
_assert(slot_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [30, 31, 32], f"slot faces should be preserved: {slot_job}")
slot_width_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-width-prepared",
backend=backend,
capability_key="slot.width",
target_value="2.5",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_width_prepared.get("ok") is True, f"slot.width should be productized and prepare an edit job: {slot_width_prepared}")
slot_width_job = read_json(slot_width_prepared["job_path"])
_assert(slot_width_job.get("target", {}).get("backendOperation") == "change_slot_width", f"slot.width should route to change_slot_width: {slot_width_job}")
_assert(slot_width_job.get("target", {}).get("value") == 2.5, f"slot.width target should be numeric: {slot_width_job}")
_assert(slot_width_job.get("object", {}).get("geometrySignature", {}).get("width") == 2.0, f"slot width signature should be preserved: {slot_width_job}")
slot_width_noop = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-width-noop",
backend=backend,
capability_key="slot.width",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(
slot_width_noop.get("ok") is False and slot_width_noop.get("reason") == "target-already-current",
f"slot.width no-op should be blocked before launching SCDM: {slot_width_noop}",
)
slot_depth_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-prepared",
backend=backend,
capability_key="slot.depth",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_depth_prepared.get("ok") is True, f"slot.depth should be productized and prepare an edit job: {slot_depth_prepared}")
slot_depth_job = read_json(slot_depth_prepared["job_path"])
_assert(slot_depth_job.get("target", {}).get("backendOperation") == "change_slot_depth", f"slot.depth should route to change_slot_depth: {slot_depth_job}")
_assert(slot_depth_job.get("target", {}).get("value") == 2.0, f"slot.depth target should be numeric: {slot_depth_job}")
slot_depth_signature = slot_depth_job.get("object", {}).get("geometrySignature", {})
_assert(slot_depth_signature.get("depth") == 1.5, f"slot depth signature should be preserved: {slot_depth_job}")
_assert(slot_depth_signature.get("depthFaceLocators"), f"slot depth bottom face locator should be preserved: {slot_depth_job}")
_assert(slot_depth_signature.get("depthAxis") == [0.0, 0.0, -1.0], f"slot depth axis should be preserved: {slot_depth_job}")
slot_depth_missing_locator_signature = dict(slot_signature)
slot_depth_missing_locator_signature.pop("depthFaceLocators", None)
slot_depth_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-missing-locator",
backend=backend,
capability_key="slot.depth",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_depth_missing_locator_signature,
)
_assert(
slot_depth_missing_locator.get("ok") is False
and slot_depth_missing_locator.get("reason") == "object-signature-missing-depth-face-locator",
f"slot.depth should be blocked before SCDM when bottom face locator is missing: {slot_depth_missing_locator}",
)
boss_signature = {
"objectType": "cylindrical_boss",
"faceIds": [50, 51, 52],
"bodyIndex": 0,
"faceOrdinal": 50,
"faceOrdinals": [50, 51, 52],
"globalFaceOrdinal": 70,
"globalFaceOrdinals": [70, 71, 72],
"height": 4.0,
"diameter": 3.0,
"heightFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 52, "globalFaceOrdinal": 72}],
"diameterFaceLocators": [{"bodyIndex": 0, "faceOrdinal": 50, "globalFaceOrdinal": 70}],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
}
boss_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-position-prepared",
backend=backend,
capability_key="boss.position",
target_value=[2.0, 0.0, 2.0],
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_prepared.get("ok") is True, f"boss.position should be productized and prepare an edit job: {boss_prepared}")
boss_job = read_json(boss_prepared["job_path"])
_assert(boss_job.get("target", {}).get("backendOperation") == "move_boss", f"boss.position should route to move_boss: {boss_job}")
_assert(boss_job.get("target", {}).get("value") == [2.0, 0.0, 2.0], f"boss.position target should be vector3: {boss_job}")
_assert(boss_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [50, 51, 52], f"boss faces should be preserved: {boss_job}")
boss_height_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-prepared",
backend=backend,
capability_key="boss.height",
target_value="5.5",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_height_prepared.get("ok") is True, f"boss.height should be productized and prepare an edit job: {boss_height_prepared}")
boss_height_job = read_json(boss_height_prepared["job_path"])
_assert(boss_height_job.get("target", {}).get("backendOperation") == "change_boss_height", f"boss.height should route to change_boss_height: {boss_height_job}")
_assert(boss_height_job.get("target", {}).get("value") == 5.5, f"boss.height target should be numeric: {boss_height_job}")
boss_height_signature = boss_height_job.get("object", {}).get("geometrySignature", {})
_assert(boss_height_signature.get("height") == 4.0, f"boss height signature should be preserved: {boss_height_job}")
_assert(boss_height_signature.get("heightFaceLocators"), f"boss height top face locator should be preserved: {boss_height_job}")
boss_height_missing_locator_signature = dict(boss_signature)
boss_height_missing_locator_signature.pop("heightFaceLocators", None)
boss_height_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-missing-locator",
backend=backend,
capability_key="boss.height",
target_value="5.5",
object_id="boss:50-51-52",
object_signature=boss_height_missing_locator_signature,
)
_assert(
boss_height_missing_locator.get("ok") is False
and boss_height_missing_locator.get("reason") == "object-signature-missing-height-face-locator",
f"boss.height should be blocked before SCDM when top face locator is missing: {boss_height_missing_locator}",
)
boss_diameter_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-prepared",
backend=backend,
capability_key="boss.diameter",
target_value="4.5",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_diameter_prepared.get("ok") is True, f"boss.diameter should be productized and prepare an edit job: {boss_diameter_prepared}")
boss_diameter_job = read_json(boss_diameter_prepared["job_path"])
_assert(boss_diameter_job.get("target", {}).get("backendOperation") == "change_boss_diameter", f"boss.diameter should route to change_boss_diameter: {boss_diameter_job}")
_assert(boss_diameter_job.get("target", {}).get("value") == 4.5, f"boss.diameter target should be numeric: {boss_diameter_job}")
boss_diameter_signature = boss_diameter_job.get("object", {}).get("geometrySignature", {})
_assert(boss_diameter_signature.get("diameter") == 3.0, f"boss diameter signature should be preserved: {boss_diameter_job}")
_assert(boss_diameter_signature.get("diameterFaceLocators"), f"boss diameter side face locator should be preserved: {boss_diameter_job}")
boss_diameter_missing_locator_signature = dict(boss_signature)
boss_diameter_missing_locator_signature.pop("diameterFaceLocators", None)
boss_diameter_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-missing-locator",
backend=backend,
capability_key="boss.diameter",
target_value="4.5",
object_id="boss:50-51-52",
object_signature=boss_diameter_missing_locator_signature,
)
_assert(
boss_diameter_missing_locator.get("ok") is False
and boss_diameter_missing_locator.get("reason") == "object-signature-missing-diameter-face-locator",
f"boss.diameter should be blocked before SCDM when side face locator is missing: {boss_diameter_missing_locator}",
)
hole_position_noop = prepare_scdm_edit_job(
step_path,
output_dir=root / "hole-position-noop",
backend=backend,
capability_key="hole.position",
target_value=[0.5, 1.0, 9.5],
object_id="hole:85-94",
object_signature=signature,
)
_assert(
hole_position_noop.get("ok") is False and hole_position_noop.get("reason") == "target-already-current",
f"hole.position no-op should be blocked before launching SCDM: {hole_position_noop}",
)
boss_height_zero = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-zero",
backend=backend,
capability_key="boss.height",
target_value="0",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(
boss_height_zero.get("ok") is False and boss_height_zero.get("reason") == "target-value-illegal",
f"positive dimensions should reject zero before launching SCDM: {boss_height_zero}",
)
round_signature = {
"objectType": "round",
"faceIds": [60],
"bodyIndex": 0,
"faceOrdinal": 60,
"globalFaceOrdinal": 80,
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.5,
"roundType": "ConstantRound",
"isConstantRound": True,
}
round_radius_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "round-radius-prepared",
backend=backend,
capability_key="round.radius",
target_value="0.75",
object_id="round:60",
object_signature=round_signature,
)
_assert(round_radius_prepared.get("ok") is True, f"round.radius should be productized and prepare an edit job: {round_radius_prepared}")
round_radius_job = read_json(round_radius_prepared["job_path"])
_assert(round_radius_job.get("target", {}).get("backendOperation") == "change_round_radius", f"round.radius should route to change_round_radius: {round_radius_job}")
_assert(round_radius_job.get("target", {}).get("value") == 0.75, f"round.radius target should be numeric: {round_radius_job}")
round_radius_signature = round_radius_job.get("object", {}).get("geometrySignature", {})
_assert(round_radius_signature.get("isConstantRound") is True, f"round.radius should preserve constant round evidence: {round_radius_job}")
_assert(round_radius_signature.get("radius") == 0.5, f"round.radius should preserve current radius: {round_radius_job}")
round_delete_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "round-delete-prepared",
backend=backend,
capability_key="feature.delete_round_or_chamfer",
target_value="",
object_id="round:60",
object_signature=round_signature,
)
_assert(round_delete_prepared.get("ok") is True, f"round/chamfer delete should be productized and prepare an edit job: {round_delete_prepared}")
round_delete_job = read_json(round_delete_prepared["job_path"])
_assert(round_delete_job.get("target", {}).get("backendOperation") == "delete_round_or_chamfer", f"round delete should route to delete_round_or_chamfer: {round_delete_job}")
_assert(round_delete_job.get("target", {}).get("value") is True, f"command target should be true: {round_delete_job}")
chamfer_signature = {
"objectType": "chamfer",
"faceIds": [63],
"bodyIndex": 0,
"faceOrdinal": 63,
"globalFaceOrdinal": 83,
"distance": 0.8,
"distance1": 0.8,
"distance2": 0.8,
"chamferType": "EqualDistanceChamfer",
"isEqualDistanceChamfer": True,
}
chamfer_distance_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "chamfer-distance-prepared",
backend=backend,
capability_key="chamfer.distance",
target_value="1.2",
object_id="chamfer:63",
object_signature=chamfer_signature,
)
_assert(chamfer_distance_prepared.get("ok") is True, f"chamfer.distance should be productized and prepare an edit job: {chamfer_distance_prepared}")
chamfer_distance_job = read_json(chamfer_distance_prepared["job_path"])
_assert(chamfer_distance_job.get("target", {}).get("backendOperation") == "change_chamfer_distance", f"chamfer.distance should route to change_chamfer_distance: {chamfer_distance_job}")
_assert(chamfer_distance_job.get("target", {}).get("value") == 1.2, f"chamfer.distance target should be numeric: {chamfer_distance_job}")
chamfer_job_signature = chamfer_distance_job.get("object", {}).get("geometrySignature", {})
_assert(chamfer_job_signature.get("isEqualDistanceChamfer") is True, f"chamfer.distance should preserve equal-distance evidence: {chamfer_distance_job}")
_assert(chamfer_job_signature.get("distance") == 0.8, f"chamfer.distance should preserve current distance: {chamfer_distance_job}")
shell_signature = {
"objectType": "thin_wall",
"faceIds": [101, 102],
"bodyIndex": 0,
"faceOrdinals": [101, 102],
"globalFaceOrdinals": [201, 202],
"thickness": 1.2,
"thicknessAxis": [0.0, 0.0, 1.0],
"wallFaceCenters": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.2]],
"wallFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 101, "globalFaceOrdinal": 201},
{"bodyIndex": 0, "faceOrdinal": 102, "globalFaceOrdinal": 202},
],
}
shell_thickness_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-prepared",
backend=backend,
capability_key="shell.thickness",
target_value="1.6",
object_id="thin_wall:101-102",
object_signature=shell_signature,
)
_assert(shell_thickness_prepared.get("ok") is True, f"shell.thickness should be productized and prepare an edit job: {shell_thickness_prepared}")
shell_thickness_job = read_json(shell_thickness_prepared["job_path"])
_assert(shell_thickness_job.get("target", {}).get("backendOperation") == "change_shell_thickness", f"shell.thickness should route to change_shell_thickness: {shell_thickness_job}")
_assert(shell_thickness_job.get("target", {}).get("value") == 1.6, f"shell.thickness target should be numeric: {shell_thickness_job}")
shell_job_signature = shell_thickness_job.get("object", {}).get("geometrySignature", {})
_assert(shell_job_signature.get("thickness") == 1.2, f"shell thickness signature should be preserved: {shell_thickness_job}")
_assert(shell_job_signature.get("wallFaceLocators"), f"shell wall face locators should be preserved: {shell_thickness_job}")
shell_missing_locator_signature = dict(shell_signature)
shell_missing_locator_signature.pop("wallFaceLocators", None)
shell_missing_locator_signature.pop("faceOrdinals", None)
shell_missing_locator_signature.pop("globalFaceOrdinals", None)
shell_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-missing-locator",
backend=backend,
capability_key="shell.thickness",
target_value="1.6",
object_id="thin_wall:101-102",
object_signature=shell_missing_locator_signature,
)
_assert(
shell_missing_locator.get("ok") is False
and shell_missing_locator.get("reason") == "object-signature-missing-wall-face-locator",
f"shell.thickness should be blocked before SCDM when both wall locators are missing: {shell_missing_locator}",
)
pattern_signature = {
"objectType": "linear_pattern",
"faceIds": [85, 94, 87, 96, 89, 98],
"bodyIndex": 0,
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
"patternInstances": [
{
"sourceObjectId": "hole:a",
"center": [0.0, 0.0, 0.0],
"faceIds": [85, 94],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 12, "globalFaceOrdinal": 85},
{"bodyIndex": 0, "faceOrdinal": 19, "globalFaceOrdinal": 94},
],
},
{
"sourceObjectId": "hole:b",
"center": [5.0, 0.0, 0.0],
"faceIds": [87, 96],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 22, "globalFaceOrdinal": 87},
{"bodyIndex": 0, "faceOrdinal": 29, "globalFaceOrdinal": 96},
],
},
{
"sourceObjectId": "hole:c",
"center": [10.0, 0.0, 0.0],
"faceIds": [89, 98],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 32, "globalFaceOrdinal": 89},
{"bodyIndex": 0, "faceOrdinal": 39, "globalFaceOrdinal": 98},
],
},
],
}
pattern_spacing_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="7.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_signature,
)
_assert(pattern_spacing_prepared.get("ok") is True, f"pattern.spacing should be productized and prepare an edit job: {pattern_spacing_prepared}")
pattern_spacing_job = read_json(pattern_spacing_prepared["job_path"])
_assert(pattern_spacing_job.get("target", {}).get("backendOperation") == "change_pattern_spacing", f"pattern.spacing should route to change_pattern_spacing: {pattern_spacing_job}")
_assert(pattern_spacing_job.get("target", {}).get("value") == 7.5, f"pattern.spacing target should be numeric: {pattern_spacing_job}")
pattern_job_signature = pattern_spacing_job.get("object", {}).get("geometrySignature", {})
_assert(pattern_job_signature.get("spacing") == 5.0, f"pattern spacing signature should be preserved: {pattern_spacing_job}")
_assert(len(pattern_job_signature.get("patternInstances") or []) == 3, f"pattern instance locators should be preserved: {pattern_spacing_job}")
pattern_segment_signature = dict(pattern_signature)
pattern_segment_signature["segmentIndex"] = 1
pattern_segment_signature["movingSide"] = "after"
pattern_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_signature,
)
_assert(pattern_segment_prepared.get("ok") is True, f"pattern.segment_spacing should prepare an edit job: {pattern_segment_prepared}")
pattern_segment_job = read_json(pattern_segment_prepared["job_path"])
_assert(
pattern_segment_job.get("target", {}).get("backendOperation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should route to change_pattern_segment_spacing: {pattern_segment_job}",
)
_assert(pattern_segment_job.get("target", {}).get("value") == 6.5, f"pattern.segment_spacing target should be numeric: {pattern_segment_job}")
_assert(
pattern_segment_job.get("object", {}).get("geometrySignature", {}).get("segmentIndex") == 1,
f"pattern.segment_spacing should preserve the selected adjacent segment: {pattern_segment_job}",
)
pattern_segment_before_signature = dict(pattern_segment_signature)
pattern_segment_before_signature["movingSide"] = "before"
pattern_segment_before_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-before-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_before_signature,
)
_assert(
pattern_segment_before_prepared.get("ok") is True
and read_json(pattern_segment_before_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "before",
f"pattern.segment_spacing should preserve fix-right/move-left semantics: {pattern_segment_before_prepared}",
)
pattern_segment_split_signature = dict(pattern_segment_signature)
pattern_segment_split_signature["movingSide"] = "split"
pattern_segment_split_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-split-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_split_signature,
)
_assert(
pattern_segment_split_prepared.get("ok") is True
and read_json(pattern_segment_split_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "split",
f"pattern.segment_spacing should preserve split/keep-center semantics: {pattern_segment_split_prepared}",
)
pattern_segment_single_left_signature = dict(pattern_segment_signature)
pattern_segment_single_left_signature["movingSide"] = "single_left"
pattern_segment_single_left_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-single-left-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_single_left_signature,
)
_assert(
pattern_segment_single_left_prepared.get("ok") is True
and read_json(pattern_segment_single_left_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_left",
f"pattern.segment_spacing should preserve move-only-left-instance semantics: {pattern_segment_single_left_prepared}",
)
pattern_segment_single_right_signature = dict(pattern_segment_signature)
pattern_segment_single_right_signature["movingSide"] = "single_right"
pattern_segment_single_right_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-single-right-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_single_right_signature,
)
_assert(
pattern_segment_single_right_prepared.get("ok") is True
and read_json(pattern_segment_single_right_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_right",
f"pattern.segment_spacing should preserve move-only-right-instance semantics: {pattern_segment_single_right_prepared}",
)
pattern_instance_signature = dict(pattern_signature["patternInstances"][1])
pattern_instance_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-prepared",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_signature,
)
_assert(pattern_instance_prepared.get("ok") is True, f"pattern.instance_position should prepare an edit job when instance locators exist: {pattern_instance_prepared}")
pattern_instance_job = read_json(pattern_instance_prepared["job_path"])
_assert(pattern_instance_job.get("target", {}).get("backendOperation") == "move_pattern_instance", f"pattern.instance_position should route to move_pattern_instance: {pattern_instance_job}")
_assert(pattern_instance_job.get("target", {}).get("value") == [6.0, 0.0, 0.0], f"pattern.instance_position target should be vector3: {pattern_instance_job}")
pattern_instance_missing_locator = dict(pattern_instance_signature)
pattern_instance_missing_locator.pop("scdmFaceLocators", None)
pattern_instance_missing_locator.pop("faceOrdinals", None)
pattern_instance_missing_locator.pop("globalFaceOrdinals", None)
pattern_instance_missing_locator.pop("bodyLocators", None)
pattern_instance_missing_locator.pop("componentLocators", None)
pattern_instance_missing_locator.pop("bodyIndex", None)
blocked_pattern_instance = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-missing-locator",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_missing_locator,
)
_assert(
blocked_pattern_instance.get("ok") is False
and blocked_pattern_instance.get("reason") == "object-signature-missing-pattern-instance-locator",
f"pattern.instance_position should be blocked before SCDM when instance locator is missing: {blocked_pattern_instance}",
)
body_pattern_signature = {
"objectType": "linear_pattern",
"patternKind": "body",
"instanceKind": "body",
"faceIds": [300, 301, 302],
"bodyIndices": [20, 21, 22],
"axis": [0.0, 1.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 20.0, 0.0], [0.0, 25.0, 0.0], [0.0, 30.0, 0.0]],
"patternInstances": [
{"sourceObjectId": "body:20", "instanceKind": "body", "center": [0.0, 20.0, 0.0], "bodyIndex": 20, "bodyLocators": [{"bodyIndex": 20}], "faceIds": [300]},
{"sourceObjectId": "body:21", "instanceKind": "body", "center": [0.0, 25.0, 0.0], "bodyIndex": 21, "bodyLocators": [{"bodyIndex": 21}], "faceIds": [301]},
{"sourceObjectId": "body:22", "instanceKind": "body", "center": [0.0, 30.0, 0.0], "bodyIndex": 22, "bodyLocators": [{"bodyIndex": 22}], "faceIds": [302]},
],
}
body_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="8",
object_id="pattern:parts-20-22",
object_signature=body_pattern_signature,
)
_assert(
body_pattern_prepared.get("ok") is False
and body_pattern_prepared.get("reason") == "body-pattern-spacing-missing-component-locators",
f"body pattern spacing should be blocked until component occurrence locators are available: {body_pattern_prepared}",
)
body_segment_pattern_signature = dict(body_pattern_signature)
body_segment_pattern_signature["segmentIndex"] = 0
body_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6",
object_id="pattern:parts-20-22",
object_signature=body_segment_pattern_signature,
)
_assert(
body_segment_prepared.get("ok") is False
and body_segment_prepared.get("reason") == "body-pattern-spacing-missing-component-locators",
f"body pattern local segment spacing should also require component occurrence locators: {body_segment_prepared}",
)
component_body_pattern_signature = dict(body_pattern_signature)
component_body_pattern_signature["componentInstanceCount"] = 3
component_body_pattern_signature["patternInstances"] = [
{
**dict(item),
"componentLocators": [
{
"componentIndex": index,
"componentPath": [index],
"componentBodyIndex": 0,
"bodyIndex": item.get("bodyIndex"),
"componentName": f"Part {index + 1}",
}
],
}
for index, item in enumerate(body_pattern_signature["patternInstances"])
]
component_body_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "component-body-pattern-spacing-prepared",
backend=backend,
capability_key="pattern.spacing",
target_value="8",
object_id="pattern:components-20-22",
object_signature=component_body_pattern_signature,
)
_assert(
component_body_pattern_prepared.get("ok") is True,
f"body pattern spacing should prepare when every member has a component occurrence locator: {component_body_pattern_prepared}",
)
component_body_pattern_job = read_json(component_body_pattern_prepared["job_path"])
component_instances = component_body_pattern_job.get("object", {}).get("geometrySignature", {}).get("patternInstances") or []
_assert(
all(item.get("componentLocators") for item in component_instances if isinstance(item, dict)),
f"component occurrence locators should be preserved in pattern.spacing job: {component_body_pattern_job}",
)
component_body_segment_signature = dict(component_body_pattern_signature)
component_body_segment_signature["segmentIndex"] = 0
component_body_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "component-body-pattern-segment-spacing-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6",
object_id="pattern:components-20-22",
object_signature=component_body_segment_signature,
)
_assert(
component_body_segment_prepared.get("ok") is True,
f"body pattern local segment spacing should prepare with component occurrence locators: {component_body_segment_prepared}",
)
blocked_pattern_signature = dict(pattern_signature)
blocked_pattern_signature["supportPatternFit"] = {
"supportFaceIds": [92],
"localUnitScale": 0.001,
"maxSpacing": 0.0012,
"maxSpacingLocal": 1.2,
"instanceCount": 3,
}
blocked_pattern_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "body-pattern-spacing-blocked",
backend=backend,
capability_key="pattern.spacing",
target_value=0.002,
object_id="pattern:holes-a-b-c",
object_signature=blocked_pattern_signature,
)
_assert(
blocked_pattern_prepared.get("ok") is False
and blocked_pattern_prepared.get("reason") == "pattern-spacing-exceeds-support",
f"pattern spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_pattern_prepared}",
)
blocked_segment_signature = dict(pattern_segment_signature)
blocked_segment_signature["supportPatternFit"] = {
"supportFaceIds": [92],
"localUnitScale": 0.001,
"maxSegmentSpacing": 0.0013,
"maxSegmentSpacingLocal": 1.3,
"instanceCount": 3,
}
blocked_segment_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-blocked",
backend=backend,
capability_key="pattern.segment_spacing",
target_value=0.002,
object_id="pattern:holes-a-b-c",
object_signature=blocked_segment_signature,
)
_assert(
blocked_segment_prepared.get("ok") is False
and blocked_segment_prepared.get("reason") == "pattern-spacing-exceeds-support",
f"pattern segment spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_segment_prepared}",
)
unsupported = prepare_scdm_edit_job(
step_path,
output_dir=root / "unsupported-unknown",
backend=backend,
capability_key="not.real",
target_value="1",
object_signature=signature,
)
_assert(unsupported.get("ok") is False and unsupported.get("reason") == "unsupported-capability", f"unknown capability should fail early: {unsupported}")
success = run_scdm_edit_job(
step_path,
output_dir=root / "success",
backend=backend,
capability_key="hole.diameter",
target_value=0.9,
object_id="hole:85-94",
object_signature=signature,
runner=_successful_runner,
)
_assert(success.get("ok") is True, f"fake edit should succeed: {success}")
_assert(Path(str(success["output_step"])).is_file(), f"output STEP should exist: {success}")
success_backend = success.get("backend")
_assert(isinstance(success_backend, dict), f"successful edit should carry backend status: {success}")
_assert(success_backend.get("runScriptOk") is True, f"successful edit should mark /RunScript usable: {success_backend}")
_assert(success_backend.get("licenseOk") is True, f"successful edit should mark license usable: {success_backend}")
failed = run_scdm_edit_job(
step_path,
output_dir=root / "failed",
backend=backend,
capability_key="hole.position",
target_value=[0.5, 1.0, 6.0],
object_id="hole:85-94",
object_signature=signature,
runner=_failure_runner,
)
_assert(failed.get("ok") is False and failed.get("reason") == "fake-failed", f"error.json should drive failure reason: {failed}")
slot_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-success",
backend=backend,
capability_key="slot.position",
target_value=[1.0, 2.0, 6.0],
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_success.get("ok") is True, f"fake slot.position edit should succeed: {slot_success}")
_assert(slot_success.get("backend_operation") == "move_slot", f"slot.position should report move_slot: {slot_success}")
slot_width_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-width-success",
backend=backend,
capability_key="slot.width",
target_value=2.5,
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_width_success.get("ok") is True, f"fake slot.width edit should succeed: {slot_width_success}")
_assert(slot_width_success.get("backend_operation") == "change_slot_width", f"slot.width should report change_slot_width: {slot_width_success}")
slot_depth_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-success",
backend=backend,
capability_key="slot.depth",
target_value=2.0,
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_depth_success.get("ok") is True, f"fake slot.depth edit should succeed: {slot_depth_success}")
_assert(slot_depth_success.get("backend_operation") == "change_slot_depth", f"slot.depth should report change_slot_depth: {slot_depth_success}")
boss_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-success",
backend=backend,
capability_key="boss.position",
target_value=[3.0, 0.0, 2.0],
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_success.get("ok") is True, f"fake boss.position edit should succeed: {boss_success}")
_assert(boss_success.get("backend_operation") == "move_boss", f"boss.position should report move_boss: {boss_success}")
boss_height_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-height-success",
backend=backend,
capability_key="boss.height",
target_value=5.5,
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_height_success.get("ok") is True, f"fake boss.height edit should succeed: {boss_height_success}")
_assert(boss_height_success.get("backend_operation") == "change_boss_height", f"boss.height should report change_boss_height: {boss_height_success}")
boss_diameter_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-success",
backend=backend,
capability_key="boss.diameter",
target_value=4.5,
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_diameter_success.get("ok") is True, f"fake boss.diameter edit should succeed: {boss_diameter_success}")
_assert(boss_diameter_success.get("backend_operation") == "change_boss_diameter", f"boss.diameter should report change_boss_diameter: {boss_diameter_success}")
round_radius_success = run_scdm_edit_job(
step_path,
output_dir=root / "round-radius-success",
backend=backend,
capability_key="round.radius",
target_value=0.75,
object_id="round:60",
object_signature=round_signature,
runner=_successful_runner,
)
_assert(round_radius_success.get("ok") is True, f"fake round.radius edit should succeed: {round_radius_success}")
_assert(round_radius_success.get("backend_operation") == "change_round_radius", f"round.radius should report change_round_radius: {round_radius_success}")
round_delete_success = run_scdm_edit_job(
step_path,
output_dir=root / "round-delete-success",
backend=backend,
capability_key="feature.delete_round_or_chamfer",
target_value="",
object_id="round:60",
object_signature=round_signature,
runner=_successful_runner,
)
_assert(round_delete_success.get("ok") is True, f"fake round/chamfer delete edit should succeed: {round_delete_success}")
_assert(round_delete_success.get("backend_operation") == "delete_round_or_chamfer", f"round/chamfer delete should report delete_round_or_chamfer: {round_delete_success}")
chamfer_distance_success = run_scdm_edit_job(
step_path,
output_dir=root / "chamfer-distance-success",
backend=backend,
capability_key="chamfer.distance",
target_value=1.2,
object_id="chamfer:63",
object_signature=chamfer_signature,
runner=_successful_runner,
)
_assert(chamfer_distance_success.get("ok") is True, f"fake chamfer.distance edit should succeed: {chamfer_distance_success}")
_assert(chamfer_distance_success.get("backend_operation") == "change_chamfer_distance", f"chamfer.distance should report change_chamfer_distance: {chamfer_distance_success}")
shell_thickness_success = run_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-success",
backend=backend,
capability_key="shell.thickness",
target_value=1.6,
object_id="thin_wall:101-102",
object_signature=shell_signature,
runner=_successful_runner,
)
_assert(shell_thickness_success.get("ok") is True, f"fake shell.thickness edit should succeed: {shell_thickness_success}")
_assert(shell_thickness_success.get("backend_operation") == "change_shell_thickness", f"shell.thickness should report change_shell_thickness: {shell_thickness_success}")
pattern_spacing_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-spacing-success",
backend=backend,
capability_key="pattern.spacing",
target_value=7.5,
object_id="pattern:holes-a-b-c",
object_signature=pattern_signature,
runner=_successful_runner,
)
_assert(pattern_spacing_success.get("ok") is True, f"fake pattern.spacing edit should succeed: {pattern_spacing_success}")
_assert(pattern_spacing_success.get("backend_operation") == "change_pattern_spacing", f"pattern.spacing should report change_pattern_spacing: {pattern_spacing_success}")
pattern_segment_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-success",
backend=backend,
capability_key="pattern.segment_spacing",
target_value=6.5,
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_signature,
runner=_successful_runner,
)
_assert(pattern_segment_success.get("ok") is True, f"fake pattern.segment_spacing edit should succeed: {pattern_segment_success}")
_assert(
pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should report change_pattern_segment_spacing: {pattern_segment_success}",
)
pattern_instance_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-success",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_signature,
runner=_successful_runner,
)
_assert(pattern_instance_success.get("ok") is True, f"fake pattern.instance_position edit should succeed: {pattern_instance_success}")
_assert(pattern_instance_success.get("backend_operation") == "move_pattern_instance", f"pattern.instance_position should report move_pattern_instance: {pattern_instance_success}")
missing_output = run_scdm_edit_job(
step_path,
output_dir=root / "missing-output",
backend=backend,
capability_key="face.offset",
target_value=5,
object_id="face:9",
object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3},
runner=_missing_output_runner,
)
_assert(missing_output.get("ok") is False and missing_output.get("reason") == "missing-output-step", f"missing output STEP should be rejected: {missing_output}")
empty_output = run_scdm_edit_job(
step_path,
output_dir=root / "empty-output",
backend=backend,
capability_key="face.offset",
target_value=5,
object_id="face:9",
object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3},
runner=_empty_output_runner,
)
_assert(empty_output.get("ok") is False and empty_output.get("reason") == "empty-output-step", f"empty output STEP should be rejected: {empty_output}")
print("scdm edit runner ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())