1146 lines
39 KiB
Python
1146 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command
|
|
from .scdm_capabilities import capability_definition
|
|
from .scdm_schema import default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json
|
|
|
|
|
|
SCDM_EDIT_SCHEMA_VERSION = 1
|
|
SCDM_EDIT_ADAPTER = "spaceclaim-v1"
|
|
|
|
|
|
def prepare_scdm_edit_job(
|
|
source_step: str | Path,
|
|
*,
|
|
capability_key: str,
|
|
target_value: object,
|
|
object_signature: Mapping[str, object] | None = None,
|
|
object_id: str = "",
|
|
backend_operation: str = "",
|
|
output_step: str | Path | None = None,
|
|
output_dir: str | Path | None = None,
|
|
project_root: str | Path | None = None,
|
|
backend: ScdmBackendInfo | None = None,
|
|
timeout_seconds: float = 180.0,
|
|
rollback_step: str | Path | None = None,
|
|
context: Mapping[str, object] | None = None,
|
|
) -> dict[str, object]:
|
|
source = Path(source_step).expanduser()
|
|
if not source.is_file():
|
|
return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"}
|
|
|
|
definition = capability_definition(capability_key)
|
|
if definition is None:
|
|
return {
|
|
"ok": False,
|
|
"reason": "unsupported-capability",
|
|
"message": f"SCDM capability is not productized yet: {capability_key}",
|
|
}
|
|
if not definition.productized:
|
|
return {
|
|
"ok": False,
|
|
"reason": "capability-not-productized",
|
|
"message": definition.block_reason or f"SCDM capability is planned but not executable yet: {capability_key}",
|
|
"capability_key": definition.key,
|
|
"roadmap_stage": definition.roadmap_stage,
|
|
}
|
|
|
|
fingerprint = file_fingerprint(source)
|
|
work_dir = _edit_work_dir(source, fingerprint=fingerprint, output_dir=output_dir, project_root=project_root).resolve(strict=False)
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = Path(output_step).expanduser().resolve(strict=False) if output_step else work_dir / "result.step"
|
|
result_path = work_dir / "result.json"
|
|
error_path = work_dir / "error.json"
|
|
job_path = work_dir / "scdm_edit_job.json"
|
|
script_path = work_dir / "scdm_edit.py"
|
|
operation = backend_operation or definition.backend_operation
|
|
|
|
_clear_stale_outputs((output_path, result_path, error_path))
|
|
|
|
job = {
|
|
"schemaVersion": SCDM_EDIT_SCHEMA_VERSION,
|
|
"adapter": SCDM_EDIT_ADAPTER,
|
|
"createdAt": utc_now(),
|
|
"backend": {
|
|
"name": "SCDM",
|
|
"path": str(backend.path) if backend else "",
|
|
"version": backend.version if backend else "",
|
|
},
|
|
"model": {
|
|
"sourceStep": str(source.resolve(strict=False)),
|
|
"rollbackStep": str(Path(rollback_step).expanduser().resolve(strict=False)) if rollback_step else str(source.resolve(strict=False)),
|
|
"fingerprint": fingerprint,
|
|
},
|
|
"object": {
|
|
"objectId": object_id,
|
|
"geometrySignature": _json_safe(dict(object_signature or {})),
|
|
},
|
|
"target": {
|
|
"capabilityKey": definition.key,
|
|
"displayName": definition.display_name,
|
|
"valueKind": definition.value_kind,
|
|
"value": _target_value_for_job(target_value, value_kind=definition.value_kind),
|
|
"text": str(target_value),
|
|
"backendOperation": operation,
|
|
"postCheck": definition.post_check,
|
|
},
|
|
"execution": {
|
|
"mode": "SpaceClaim.exe /RunScript",
|
|
"timeoutSeconds": max(float(timeout_seconds), 0.1),
|
|
"isolatedProcess": True,
|
|
},
|
|
"outputs": {
|
|
"outputStep": str(output_path),
|
|
"result": str(result_path),
|
|
"error": str(error_path),
|
|
},
|
|
"context": _json_safe(dict(context or {})),
|
|
}
|
|
write_json(job_path, job)
|
|
script_path.write_text(generate_scdm_edit_script(job_path), encoding="utf-8")
|
|
|
|
return {
|
|
"ok": True,
|
|
"reason": "ok",
|
|
"work_dir": str(work_dir),
|
|
"job_path": str(job_path),
|
|
"script_path": str(script_path),
|
|
"output_step": str(output_path),
|
|
"result_path": str(result_path),
|
|
"error_path": str(error_path),
|
|
"model_fingerprint": fingerprint,
|
|
"capability_key": definition.key,
|
|
"backend_operation": operation,
|
|
}
|
|
|
|
|
|
def run_scdm_edit_job(
|
|
source_step: str | Path,
|
|
*,
|
|
capability_key: str,
|
|
target_value: object,
|
|
object_signature: Mapping[str, object] | None = None,
|
|
object_id: str = "",
|
|
backend_operation: str = "",
|
|
output_step: str | Path | None = None,
|
|
output_dir: str | Path | None = None,
|
|
project_root: str | Path | None = None,
|
|
backend: ScdmBackendInfo | None = None,
|
|
timeout_seconds: float = 180.0,
|
|
rollback_step: str | Path | None = None,
|
|
context: Mapping[str, object] | None = None,
|
|
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
) -> dict[str, object]:
|
|
if backend is None:
|
|
resolved = resolve_scdm_backend(project_root_override=project_root, validate=False)
|
|
if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo):
|
|
return {
|
|
"ok": False,
|
|
"reason": str(resolved.get("reason") or "missing-scdm"),
|
|
"message": str(resolved.get("message") or "SCDM backend is not available."),
|
|
}
|
|
backend = resolved["backend"] # type: ignore[assignment]
|
|
|
|
prepared = prepare_scdm_edit_job(
|
|
source_step,
|
|
capability_key=capability_key,
|
|
target_value=target_value,
|
|
object_signature=object_signature,
|
|
object_id=object_id,
|
|
backend_operation=backend_operation,
|
|
output_step=output_step,
|
|
output_dir=output_dir,
|
|
project_root=project_root,
|
|
backend=backend,
|
|
timeout_seconds=timeout_seconds,
|
|
rollback_step=rollback_step,
|
|
context=context,
|
|
)
|
|
if not prepared.get("ok"):
|
|
return prepared
|
|
|
|
result = run_prepared_scdm_edit_job(prepared, backend=backend, timeout_seconds=timeout_seconds, runner=runner)
|
|
if result.get("ok") is True:
|
|
try:
|
|
save_scdm_backend_cache(_edit_verified_backend(backend), project_root_override=project_root)
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
|
|
def run_prepared_scdm_edit_job(
|
|
prepared: Mapping[str, object],
|
|
*,
|
|
backend: ScdmBackendInfo,
|
|
timeout_seconds: float = 180.0,
|
|
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
) -> dict[str, object]:
|
|
script_path = Path(str(prepared.get("script_path") or "")).expanduser()
|
|
result_path = Path(str(prepared.get("result_path") or "")).expanduser()
|
|
error_path = Path(str(prepared.get("error_path") or "")).expanduser()
|
|
output_step = Path(str(prepared.get("output_step") or "")).expanduser()
|
|
if not script_path.is_file():
|
|
return _with_prepared(
|
|
prepared,
|
|
ok=False,
|
|
reason="missing-script",
|
|
message=f"SCDM edit script not found: {script_path}",
|
|
backend=backend.to_cache(),
|
|
)
|
|
|
|
command = scdm_run_script_command(backend.path, script_path)
|
|
run = runner or subprocess.run
|
|
try:
|
|
completed = run(
|
|
command,
|
|
cwd=str(script_path.parent),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=max(float(timeout_seconds), 0.1),
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
_write_error(
|
|
error_path,
|
|
reason="timeout",
|
|
message="SCDM edit timed out.",
|
|
prepared=prepared,
|
|
)
|
|
return _with_prepared(prepared, ok=False, reason="timeout", message="SCDM edit timed out.")
|
|
except OSError as exc:
|
|
_write_error(
|
|
error_path,
|
|
reason="launch-failed",
|
|
message=str(exc),
|
|
prepared=prepared,
|
|
)
|
|
return _with_prepared(prepared, ok=False, reason="launch-failed", message=str(exc))
|
|
|
|
return _result_from_completed(completed, prepared=prepared, result_path=result_path, error_path=error_path, output_step=output_step, backend=backend)
|
|
|
|
|
|
def generate_scdm_edit_script(job_path: str | Path) -> str:
|
|
job_literal = repr(str(Path(job_path).expanduser()))
|
|
return _SCDM_EDIT_SCRIPT_TEMPLATE.replace("__STEP_EDITOR_SCDM_EDIT_JOB_PATH__", job_literal)
|
|
|
|
|
|
def _edit_work_dir(
|
|
source: Path,
|
|
*,
|
|
fingerprint: str,
|
|
output_dir: str | Path | None,
|
|
project_root: str | Path | None,
|
|
) -> Path:
|
|
if output_dir:
|
|
return Path(output_dir).expanduser()
|
|
return default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint) / "edit"
|
|
|
|
|
|
def _clear_stale_outputs(paths: tuple[Path, ...]) -> None:
|
|
for path in paths:
|
|
try:
|
|
if path.exists():
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _target_value_for_job(value: object, *, value_kind: str) -> object:
|
|
if value_kind == "vector3":
|
|
parsed = _parse_vector3(value)
|
|
return parsed if parsed else _json_safe(value)
|
|
if value_kind in {"number", "length", "angle"}:
|
|
try:
|
|
return float(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
return _json_safe(value)
|
|
if value_kind == "command":
|
|
return True if value in ("", None) else _json_safe(value)
|
|
return _json_safe(value)
|
|
|
|
|
|
def _parse_vector3(value: object) -> list[float]:
|
|
if isinstance(value, (list, tuple)):
|
|
items = list(value)
|
|
elif isinstance(value, str):
|
|
text = value.strip().strip("()[]")
|
|
if not text:
|
|
return []
|
|
items = text.replace(",", " ").split()
|
|
else:
|
|
return []
|
|
if len(items) != 3:
|
|
return []
|
|
try:
|
|
return [float(item) for item in items]
|
|
except (TypeError, ValueError):
|
|
return []
|
|
|
|
|
|
def _json_safe(value: object) -> object:
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_json_safe(item) for item in value]
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def _result_from_completed(
|
|
completed: subprocess.CompletedProcess[str],
|
|
*,
|
|
prepared: Mapping[str, object],
|
|
result_path: Path,
|
|
error_path: Path,
|
|
output_step: Path,
|
|
backend: ScdmBackendInfo,
|
|
) -> dict[str, object]:
|
|
returncode = int(getattr(completed, "returncode", -1))
|
|
stdout = str(getattr(completed, "stdout", "") or "")
|
|
stderr = str(getattr(completed, "stderr", "") or "")
|
|
|
|
if error_path.is_file():
|
|
error = _read_json_or_message(error_path)
|
|
reason = str(error.get("reason") or "scdm-edit-failed")
|
|
message = str(error.get("message") or stderr or stdout or "SCDM edit failed.")
|
|
return {
|
|
**dict(prepared),
|
|
"ok": False,
|
|
"reason": reason,
|
|
"message": message,
|
|
"returncode": returncode,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"error": error,
|
|
"backend": backend.to_cache(),
|
|
}
|
|
|
|
if returncode != 0:
|
|
message = (stderr or stdout or f"SCDM returned {returncode}.").strip()
|
|
_write_error(error_path, reason="edit-failed", message=message, prepared=prepared, returncode=returncode)
|
|
return {
|
|
**dict(prepared),
|
|
"ok": False,
|
|
"reason": "edit-failed",
|
|
"message": message,
|
|
"returncode": returncode,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"backend": backend.to_cache(),
|
|
}
|
|
|
|
if not result_path.is_file():
|
|
message = "SCDM edit finished but did not write result.json."
|
|
_write_error(error_path, reason="missing-result", message=message, prepared=prepared, returncode=returncode)
|
|
return _with_prepared(prepared, ok=False, reason="missing-result", message=message, returncode=returncode, backend=backend.to_cache())
|
|
|
|
result = _read_json_or_message(result_path)
|
|
if result.get("ok") is not True:
|
|
reason = str(result.get("reason") or "negative-result")
|
|
message = str(result.get("message") or "SCDM edit result was not successful.")
|
|
return _with_prepared(prepared, ok=False, reason=reason, message=message, returncode=returncode, result=result, backend=backend.to_cache())
|
|
|
|
if not output_step.is_file():
|
|
message = f"SCDM edit reported success but output STEP was not written: {output_step}"
|
|
_write_error(error_path, reason="missing-output-step", message=message, prepared=prepared, returncode=returncode)
|
|
return {
|
|
**dict(prepared),
|
|
"ok": False,
|
|
"reason": "missing-output-step",
|
|
"message": message,
|
|
"returncode": returncode,
|
|
"result": result,
|
|
"backend": backend.to_cache(),
|
|
}
|
|
try:
|
|
output_size = output_step.stat().st_size
|
|
except OSError:
|
|
output_size = -1
|
|
if output_size <= 0:
|
|
message = f"SCDM edit reported success but output STEP is empty: {output_step}"
|
|
_write_error(error_path, reason="empty-output-step", message=message, prepared=prepared, returncode=returncode)
|
|
return {
|
|
**dict(prepared),
|
|
"ok": False,
|
|
"reason": "empty-output-step",
|
|
"message": message,
|
|
"returncode": returncode,
|
|
"result": result,
|
|
"backend": backend.to_cache(),
|
|
}
|
|
|
|
return {
|
|
**dict(prepared),
|
|
"ok": True,
|
|
"reason": "ok",
|
|
"message": str(result.get("message") or "SCDM edit finished."),
|
|
"returncode": returncode,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"result": result,
|
|
"output_step": str(output_step),
|
|
"backend": _edit_verified_backend(backend).to_cache(),
|
|
}
|
|
|
|
|
|
def _edit_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo:
|
|
return ScdmBackendInfo(
|
|
path=backend.path,
|
|
source=backend.source,
|
|
version=backend.version,
|
|
verified_at=utc_now(),
|
|
run_script_ok=True,
|
|
license_ok=True,
|
|
message="SCDM edit completed.",
|
|
)
|
|
|
|
|
|
def _with_prepared(prepared: Mapping[str, object], **payload: object) -> dict[str, object]:
|
|
result = dict(prepared)
|
|
result.update(payload)
|
|
return result
|
|
|
|
|
|
def _write_error(path: Path, *, reason: str, message: str, prepared: Mapping[str, object], **extra: object) -> None:
|
|
payload = {
|
|
"ok": False,
|
|
"reason": reason,
|
|
"message": message,
|
|
"createdAt": utc_now(),
|
|
"jobPath": str(prepared.get("job_path") or ""),
|
|
"scriptPath": str(prepared.get("script_path") or ""),
|
|
"outputStep": str(prepared.get("output_step") or ""),
|
|
}
|
|
payload.update(extra)
|
|
write_json(path, payload)
|
|
|
|
|
|
def _read_json_or_message(path: Path) -> dict[str, object]:
|
|
try:
|
|
return read_json(path)
|
|
except Exception as exc:
|
|
return {"ok": False, "reason": "bad-json", "message": str(exc)}
|
|
|
|
|
|
_SCDM_EDIT_SCRIPT_TEMPLATE = r'''
|
|
from __future__ import print_function
|
|
import json
|
|
import traceback
|
|
|
|
JOB_PATH = __STEP_EDITOR_SCDM_EDIT_JOB_PATH__
|
|
|
|
|
|
def _write_json(path, payload):
|
|
handle = open(path, 'w')
|
|
try:
|
|
handle.write(json.dumps(payload, indent=2))
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def _read_job():
|
|
handle = open(JOB_PATH, 'r')
|
|
try:
|
|
return json.loads(handle.read())
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def _items(collection):
|
|
if collection is None:
|
|
return []
|
|
try:
|
|
return list(collection)
|
|
except Exception:
|
|
result = []
|
|
try:
|
|
count = int(collection.Count)
|
|
for index in range(count):
|
|
result.append(collection[index])
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
|
|
def _maybe_call(target, name, *args):
|
|
try:
|
|
func = getattr(target, name)
|
|
except Exception:
|
|
return None
|
|
try:
|
|
return func(*args)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _open_step(path):
|
|
errors = []
|
|
document_open = globals().get('DocumentOpen')
|
|
if document_open is not None:
|
|
for args in ((path,),):
|
|
try:
|
|
return document_open.Execute(*args)
|
|
except Exception as exc:
|
|
errors.append('DocumentOpen.Execute: ' + str(exc))
|
|
application = globals().get('Application')
|
|
if application is not None:
|
|
for name in ('OpenDocument', 'OpenFile'):
|
|
try:
|
|
return getattr(application, name)(path)
|
|
except Exception as exc:
|
|
errors.append('Application.' + name + ': ' + str(exc))
|
|
raise Exception('open_step_failed: ' + '; '.join(errors))
|
|
|
|
|
|
def _root_part():
|
|
get_root_part = globals().get('GetRootPart')
|
|
if get_root_part is not None:
|
|
try:
|
|
return get_root_part()
|
|
except Exception:
|
|
pass
|
|
application = globals().get('Application')
|
|
if application is not None:
|
|
for expr in ('ActiveWindow.Document.MainPart', 'ActiveDocument.MainPart', 'Document.MainPart'):
|
|
value = application
|
|
ok = True
|
|
for name in expr.split('.'):
|
|
try:
|
|
value = getattr(value, name)
|
|
except Exception:
|
|
ok = False
|
|
break
|
|
if ok and value is not None:
|
|
return value
|
|
raise Exception('root_part_not_found')
|
|
|
|
|
|
def _bodies(root):
|
|
for name in ('Bodies', 'GetAllBodies'):
|
|
value = _maybe_call(root, name)
|
|
if value is None:
|
|
try:
|
|
value = getattr(root, name)
|
|
except Exception:
|
|
value = None
|
|
items = _items(value)
|
|
if items:
|
|
return items
|
|
return []
|
|
|
|
|
|
def _faces(body):
|
|
for name in ('Faces', 'GetFaces'):
|
|
value = _maybe_call(body, name)
|
|
if value is None:
|
|
try:
|
|
value = getattr(body, name)
|
|
except Exception:
|
|
value = None
|
|
items = _items(value)
|
|
if items:
|
|
return items
|
|
return []
|
|
|
|
|
|
def _flat_faces(bodies):
|
|
result = []
|
|
for body in bodies:
|
|
result.extend(_faces(body))
|
|
return result
|
|
|
|
|
|
def _int_or_none(value):
|
|
try:
|
|
return int(value)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _append_unique_face(result, face):
|
|
for existing in result:
|
|
try:
|
|
if existing == face:
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if existing is face:
|
|
return
|
|
except Exception:
|
|
pass
|
|
result.append(face)
|
|
|
|
|
|
def _locate_one_face(bodies, body_index, face_ordinal, global_face_ordinal):
|
|
if body_index is not None and face_ordinal is not None and 0 <= body_index < len(bodies):
|
|
faces = _faces(bodies[body_index])
|
|
if 0 <= face_ordinal < len(faces):
|
|
return faces[face_ordinal]
|
|
flat_ordinal = global_face_ordinal if global_face_ordinal is not None else face_ordinal
|
|
if flat_ordinal is not None:
|
|
flat = _flat_faces(bodies)
|
|
if 0 <= flat_ordinal < len(flat):
|
|
return flat[flat_ordinal]
|
|
return None
|
|
|
|
|
|
def _locate_faces(signature):
|
|
root = _root_part()
|
|
bodies = _bodies(root)
|
|
body_index = _int_or_none(signature.get('bodyIndex'))
|
|
face_ordinal = _int_or_none(signature.get('faceOrdinal'))
|
|
global_face_ordinal = _int_or_none(signature.get('globalFaceOrdinal'))
|
|
result = []
|
|
locators = signature.get('scdmFaceLocators')
|
|
if isinstance(locators, list):
|
|
for locator in locators:
|
|
if not isinstance(locator, dict):
|
|
continue
|
|
face = _locate_one_face(
|
|
bodies,
|
|
_int_or_none(locator.get('bodyIndex')),
|
|
_int_or_none(locator.get('faceOrdinal')),
|
|
_int_or_none(locator.get('globalFaceOrdinal')),
|
|
)
|
|
if face is not None:
|
|
_append_unique_face(result, face)
|
|
face_ordinals = signature.get('faceOrdinals')
|
|
if isinstance(face_ordinals, list) and body_index is not None:
|
|
for item in face_ordinals:
|
|
face = _locate_one_face(bodies, body_index, _int_or_none(item), None)
|
|
if face is not None:
|
|
_append_unique_face(result, face)
|
|
global_face_ordinals = signature.get('globalFaceOrdinals')
|
|
if isinstance(global_face_ordinals, list):
|
|
for item in global_face_ordinals:
|
|
face = _locate_one_face(bodies, None, None, _int_or_none(item))
|
|
if face is not None:
|
|
_append_unique_face(result, face)
|
|
if result:
|
|
return result
|
|
face = _locate_one_face(bodies, body_index, face_ordinal, global_face_ordinal)
|
|
if face is not None:
|
|
return [face]
|
|
raise Exception('object_not_found: geometrySignature does not locate a unique SCDM face')
|
|
|
|
|
|
def _selection(items):
|
|
selection = globals().get('Selection')
|
|
if selection is not None:
|
|
try:
|
|
return selection.Create(items)
|
|
except Exception:
|
|
pass
|
|
if len(items) == 1:
|
|
try:
|
|
return selection.Create(items[0])
|
|
except Exception:
|
|
pass
|
|
return items[0] if len(items) == 1 else items
|
|
|
|
|
|
def _import_attr(module_name, attr_name):
|
|
try:
|
|
module = __import__(module_name, fromlist=[attr_name])
|
|
return getattr(module, attr_name)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _api_versions():
|
|
return ('V23', 'V22', 'V21', 'V20', 'V19', 'V18', 'V17', 'V16')
|
|
|
|
|
|
def _command_type(name):
|
|
value = globals().get(name)
|
|
if value is not None:
|
|
return value
|
|
for version in _api_versions():
|
|
value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Commands', name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _command_option_type(name):
|
|
value = globals().get(name)
|
|
if value is not None:
|
|
return value
|
|
for version in _api_versions():
|
|
value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Commands.CommandOptions', name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _selection_type():
|
|
value = globals().get('Selection')
|
|
if value is not None:
|
|
return value
|
|
for version in _api_versions():
|
|
value = _import_attr('SpaceClaim.Api.' + version + '.Scripting.Selection', 'Selection')
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _geometry_type(name):
|
|
value = globals().get(name)
|
|
if value is not None:
|
|
return value
|
|
for version in _api_versions():
|
|
value = _import_attr('SpaceClaim.Api.' + version + '.Geometry', name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _new_options(name):
|
|
cls = _command_option_type(name)
|
|
if cls is None:
|
|
return None
|
|
try:
|
|
options = cls()
|
|
except Exception:
|
|
return None
|
|
for attr, value in (('Copy', False), ('DetachFirst', False), ('CreatePatterns', False)):
|
|
try:
|
|
if hasattr(options, attr):
|
|
setattr(options, attr, value)
|
|
except Exception:
|
|
pass
|
|
return options
|
|
|
|
|
|
def _empty_selection():
|
|
selection = _selection_type()
|
|
if selection is None:
|
|
return None
|
|
for name in ('Empty', 'Create'):
|
|
try:
|
|
return getattr(selection, name)()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _fill_mode_three_d():
|
|
fill_mode = _command_type('FillMode')
|
|
if fill_mode is None:
|
|
return None
|
|
for name in ('ThreeD', 'ThreeDMode'):
|
|
try:
|
|
return getattr(fill_mode, name)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _make_vector(x, y, z):
|
|
vector_type = _geometry_type('Vector')
|
|
if vector_type is None:
|
|
return None
|
|
for maker in ('Create', 'CreateVector'):
|
|
try:
|
|
return getattr(vector_type, maker)(x, y, z)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return vector_type(x, y, z)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _make_direction(x, y, z):
|
|
direction_type = _geometry_type('Direction')
|
|
if direction_type is None:
|
|
return None
|
|
for maker in ('Create', 'CreateDirection'):
|
|
try:
|
|
return getattr(direction_type, maker)(x, y, z)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return direction_type(x, y, z)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _vector_length(values):
|
|
return (values[0] * values[0] + values[1] * values[1] + values[2] * values[2]) ** 0.5
|
|
|
|
|
|
def _unit_direction_and_distance(values):
|
|
distance = _vector_length(values)
|
|
if distance <= 1e-12:
|
|
return None, 0.0
|
|
direction = _make_direction(values[0] / distance, values[1] / distance, values[2] / distance)
|
|
return direction, distance
|
|
|
|
|
|
def _signature_direction(signature):
|
|
for key in ('normal', 'axis'):
|
|
value = signature.get(key)
|
|
if isinstance(value, list) and len(value) == 3:
|
|
try:
|
|
direction = _make_direction(float(value[0]), float(value[1]), float(value[2]))
|
|
if direction is not None:
|
|
return direction
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _call_variants(label, func, variants):
|
|
errors = []
|
|
for args in variants:
|
|
if args is None:
|
|
continue
|
|
try:
|
|
result = func(*args)
|
|
return {'ok': True, 'result': result, 'signature': label + '(' + str(len(args)) + ' args)'}
|
|
except Exception as exc:
|
|
errors.append(label + '(' + str(len(args)) + ' args): ' + str(exc))
|
|
return {'ok': False, 'errors': errors}
|
|
|
|
|
|
def _offset_faces(selection, distance, signature):
|
|
offset_faces = _command_type('OffsetFaces')
|
|
if offset_faces is None:
|
|
raise Exception('capability_not_implemented: OffsetFaces command not available')
|
|
options = _new_options('OffsetFaceOptions')
|
|
direction = _signature_direction(signature)
|
|
variants = []
|
|
if direction is not None:
|
|
variants.append((selection, distance, direction, options, None))
|
|
variants.append((selection, distance, direction, options))
|
|
variants.append((selection, distance, options, None))
|
|
variants.append((selection, distance, options))
|
|
result = _call_variants('OffsetFaces.Execute', offset_faces.Execute, variants)
|
|
if result.get('ok'):
|
|
return {'command': 'OffsetFaces.Execute', 'distance': distance, 'apiSignature': result.get('signature')}
|
|
raise Exception('capability_not_implemented: OffsetFaces.Execute failed; ' + '; '.join(result.get('errors') or []))
|
|
|
|
|
|
def _translate_selection(selection, delta):
|
|
move = _command_type('Move')
|
|
if move is None:
|
|
raise Exception('capability_not_implemented: Move command not available')
|
|
options = _new_options('MoveOptions')
|
|
vector = _make_vector(delta[0], delta[1], delta[2])
|
|
variants = []
|
|
if vector is not None:
|
|
variants.append((selection, vector, options, None))
|
|
variants.append((selection, vector, options))
|
|
direction, distance = _unit_direction_and_distance(delta)
|
|
if direction is not None:
|
|
variants.append((selection, direction, distance, options, None))
|
|
variants.append((selection, direction, distance, options))
|
|
if vector is not None:
|
|
variants.append((selection, vector))
|
|
result = _call_variants('Move.Translate', move.Translate, variants)
|
|
if result.get('ok'):
|
|
return {'command': 'Move.Translate', 'delta': delta, 'apiSignature': result.get('signature')}
|
|
execute = getattr(move, 'Execute', None)
|
|
if execute is not None and vector is not None:
|
|
fallback = _call_variants('Move.Execute', execute, ((selection, vector, options), (selection, vector)))
|
|
if fallback.get('ok'):
|
|
return {'command': 'Move.Execute', 'delta': delta, 'apiSignature': fallback.get('signature')}
|
|
result['errors'].extend(fallback.get('errors') or [])
|
|
raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or []))
|
|
|
|
|
|
def _fill_selection(selection):
|
|
fill = _command_type('Fill')
|
|
if fill is None:
|
|
raise Exception('capability_not_implemented: Fill command not available')
|
|
options = _new_options('FillOptions')
|
|
for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)):
|
|
try:
|
|
if options is not None and hasattr(options, attr):
|
|
setattr(options, attr, value)
|
|
except Exception:
|
|
pass
|
|
secondary = _empty_selection()
|
|
mode = _fill_mode_three_d()
|
|
variants = []
|
|
if mode is not None:
|
|
variants.append((selection, secondary, options, mode, None))
|
|
variants.append((selection, secondary, options, mode))
|
|
variants.append((selection, None, options, mode, None))
|
|
variants.append((selection, None, options, mode))
|
|
variants.append((selection, secondary, options, None, None))
|
|
variants.append((selection, secondary, options, None))
|
|
variants.append((selection, options, None))
|
|
variants.append((selection, options))
|
|
variants.append((selection, None))
|
|
variants.append((selection,))
|
|
result = _call_variants('Fill.Execute', fill.Execute, variants)
|
|
if result.get('ok'):
|
|
return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')}
|
|
delete = _command_type('Delete')
|
|
if delete is not None:
|
|
fallback = _call_variants('Delete.Execute', delete.Execute, ((selection,),))
|
|
if fallback.get('ok'):
|
|
return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': result.get('errors') or []}
|
|
result['errors'].extend(fallback.get('errors') or [])
|
|
raise Exception('capability_not_implemented: Fill.Execute failed; ' + '; '.join(result.get('errors') or []))
|
|
|
|
|
|
def _float_value(value):
|
|
try:
|
|
return float(value)
|
|
except Exception:
|
|
raise Exception('target_value_not_numeric: ' + str(value))
|
|
|
|
|
|
def _vector3(value):
|
|
if not isinstance(value, list) or len(value) != 3:
|
|
raise Exception('target_value_not_vector3: ' + str(value))
|
|
return [float(value[0]), float(value[1]), float(value[2])]
|
|
|
|
|
|
def _save_step(path):
|
|
errors = []
|
|
for name in ('DocumentSave', 'DocumentSaveAs'):
|
|
command = globals().get(name)
|
|
if command is None:
|
|
command = _command_type(name)
|
|
if command is None:
|
|
continue
|
|
try:
|
|
command.Execute(path)
|
|
return {'command': name + '.Execute'}
|
|
except Exception as exc:
|
|
errors.append(name + '.Execute: ' + str(exc))
|
|
application = globals().get('Application')
|
|
if application is not None:
|
|
for expr in ('ActiveWindow.Document', 'ActiveDocument', 'Document'):
|
|
document = application
|
|
ok = True
|
|
for name in expr.split('.'):
|
|
try:
|
|
document = getattr(document, name)
|
|
except Exception:
|
|
ok = False
|
|
break
|
|
if not ok or document is None:
|
|
continue
|
|
for method in ('SaveAs', 'SaveCopyAs', 'Export'):
|
|
try:
|
|
getattr(document, method)(path)
|
|
return {'command': expr + '.' + method}
|
|
except Exception as exc:
|
|
errors.append(expr + '.' + method + ': ' + str(exc))
|
|
raise Exception('save_step_failed: ' + '; '.join(errors))
|
|
|
|
|
|
def change_hole_diameter(job):
|
|
target = _float_value(job['target']['value'])
|
|
if target <= 0:
|
|
raise Exception('target_value_illegal: diameter must be positive')
|
|
radius = target / 2.0
|
|
signature = job['object'].get('geometrySignature') or {}
|
|
faces = _locate_faces(signature)
|
|
selection = _selection(faces)
|
|
errors = []
|
|
standard_holes = _command_type('StandardHoles')
|
|
if standard_holes is not None:
|
|
for args in ((selection, radius, None), (selection, radius), (faces, radius, None), (faces, radius)):
|
|
try:
|
|
standard_holes.ModifyHoleRadius(*args)
|
|
return {'command': 'StandardHoles.ModifyHoleRadius', 'targetRadius': radius, 'apiSignature': str(len(args)) + ' args'}
|
|
except Exception as exc:
|
|
errors.append('StandardHoles.ModifyHoleRadius: ' + str(exc))
|
|
current_radius = None
|
|
for key in ('radius',):
|
|
try:
|
|
current_radius = float(signature.get(key))
|
|
break
|
|
except Exception:
|
|
pass
|
|
if current_radius is None:
|
|
try:
|
|
current_radius = float(signature.get('diameter')) / 2.0
|
|
except Exception:
|
|
current_radius = None
|
|
if current_radius is not None:
|
|
# For internal cylindrical hole walls, SpaceClaim's positive face offset
|
|
# follows the face normal into the void and reduces the measured radius.
|
|
radial_delta = current_radius - radius
|
|
if abs(radial_delta) <= 1e-12:
|
|
return {'command': 'noop', 'targetRadius': radius, 'reason': 'target already reached'}
|
|
try:
|
|
applied = _offset_faces(selection, radial_delta, signature)
|
|
applied['targetRadius'] = radius
|
|
applied['radialDelta'] = radial_delta
|
|
applied['standardHoleErrors'] = errors
|
|
return applied
|
|
except Exception as exc:
|
|
errors.append('OffsetFaces.Execute: ' + str(exc))
|
|
raise Exception('capability_not_implemented: hole.diameter adapter failed; ' + '; '.join(errors))
|
|
|
|
|
|
def move_hole_axis(job):
|
|
target = _vector3(job['target']['value'])
|
|
signature = job['object'].get('geometrySignature') or {}
|
|
center = signature.get('center') or []
|
|
if not isinstance(center, list) or len(center) != 3:
|
|
raise Exception('object_signature_missing_center')
|
|
delta = [float(target[index]) - float(center[index]) for index in range(3)]
|
|
if _vector_length(delta) <= 1e-12:
|
|
return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'}
|
|
faces = _locate_faces(signature)
|
|
selection = _selection(faces)
|
|
try:
|
|
return _translate_selection(selection, delta)
|
|
except Exception as exc:
|
|
raise Exception('capability_not_implemented: hole.position adapter failed; ' + str(exc))
|
|
|
|
|
|
def move_slot(job):
|
|
target = _vector3(job['target']['value'])
|
|
signature = job['object'].get('geometrySignature') or {}
|
|
center = signature.get('center') or signature.get('axisCenter') or []
|
|
if not isinstance(center, list) or len(center) != 3:
|
|
raise Exception('object_signature_missing_center')
|
|
delta = [float(target[index]) - float(center[index]) for index in range(3)]
|
|
if _vector_length(delta) <= 1e-12:
|
|
return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'}
|
|
faces = _locate_faces(signature)
|
|
selection = _selection(faces)
|
|
try:
|
|
applied = _translate_selection(selection, delta)
|
|
applied['targetCenter'] = target
|
|
return applied
|
|
except Exception as exc:
|
|
raise Exception('capability_not_implemented: slot.position adapter failed; ' + str(exc))
|
|
|
|
|
|
def move_boss(job):
|
|
target = _vector3(job['target']['value'])
|
|
signature = job['object'].get('geometrySignature') or {}
|
|
center = signature.get('center') or signature.get('axisCenter') or []
|
|
if not isinstance(center, list) or len(center) != 3:
|
|
raise Exception('object_signature_missing_center')
|
|
delta = [float(target[index]) - float(center[index]) for index in range(3)]
|
|
if _vector_length(delta) <= 1e-12:
|
|
return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'}
|
|
faces = _locate_faces(signature)
|
|
selection = _selection(faces)
|
|
try:
|
|
applied = _translate_selection(selection, delta)
|
|
applied['targetCenter'] = target
|
|
return applied
|
|
except Exception as exc:
|
|
raise Exception('capability_not_implemented: boss.position adapter failed; ' + str(exc))
|
|
|
|
|
|
def pull_face_offset(job):
|
|
target = _float_value(job['target']['value'])
|
|
signature = job['object'].get('geometrySignature') or {}
|
|
current = None
|
|
try:
|
|
current = float(signature.get('planeOffset'))
|
|
except Exception:
|
|
current = None
|
|
distance = target - current if current is not None else target
|
|
if abs(distance) <= 1e-12:
|
|
return {'command': 'noop', 'distance': distance, 'reason': 'target already reached'}
|
|
faces = _locate_faces(signature)
|
|
selection = _selection(faces)
|
|
try:
|
|
applied = _offset_faces(selection, distance, signature)
|
|
applied['targetPlaneOffset'] = target
|
|
return applied
|
|
except Exception as exc:
|
|
raise Exception('capability_not_implemented: face.offset adapter failed; ' + str(exc))
|
|
|
|
|
|
def fill_feature(job):
|
|
faces = _locate_faces(job['object'].get('geometrySignature') or {})
|
|
selection = _selection(faces)
|
|
try:
|
|
return _fill_selection(selection)
|
|
except Exception as exc:
|
|
raise Exception('capability_not_implemented: feature.fill adapter failed; ' + str(exc))
|
|
|
|
|
|
def _apply_edit(job):
|
|
operation = job['target'].get('backendOperation') or ''
|
|
if operation == 'change_hole_diameter':
|
|
return change_hole_diameter(job)
|
|
if operation == 'move_hole_axis':
|
|
return move_hole_axis(job)
|
|
if operation == 'move_slot':
|
|
return move_slot(job)
|
|
if operation == 'move_boss':
|
|
return move_boss(job)
|
|
if operation == 'pull_face_offset':
|
|
return pull_face_offset(job)
|
|
if operation == 'fill_feature':
|
|
return fill_feature(job)
|
|
raise Exception('unsupported_backend_operation: ' + operation)
|
|
|
|
|
|
def main():
|
|
job = _read_job()
|
|
outputs = job.get('outputs') or {}
|
|
error_path = outputs.get('error') or 'error.json'
|
|
result_path = outputs.get('result') or 'result.json'
|
|
output_step = outputs.get('outputStep') or 'result.step'
|
|
try:
|
|
_open_step(job['model']['sourceStep'])
|
|
applied = _apply_edit(job)
|
|
saved = _save_step(output_step)
|
|
_write_json(result_path, {
|
|
'ok': True,
|
|
'reason': 'ok',
|
|
'message': 'SCDM edit finished.',
|
|
'capabilityKey': job['target'].get('capabilityKey'),
|
|
'backendOperation': job['target'].get('backendOperation'),
|
|
'objectId': job['object'].get('objectId'),
|
|
'targetValue': job['target'].get('value'),
|
|
'outputStep': output_step,
|
|
'applied': applied,
|
|
'saved': saved,
|
|
})
|
|
except Exception as exc:
|
|
_write_json(error_path, {
|
|
'ok': False,
|
|
'reason': str(exc).split(':', 1)[0].replace('_', '-'),
|
|
'message': str(exc),
|
|
'traceback': traceback.format_exc(),
|
|
'capabilityKey': (job.get('target') or {}).get('capabilityKey') if 'job' in locals() else '',
|
|
'backendOperation': (job.get('target') or {}).get('backendOperation') if 'job' in locals() else '',
|
|
'objectId': (job.get('object') or {}).get('objectId') if 'job' in locals() else '',
|
|
'outputStep': output_step if 'output_step' in locals() else '',
|
|
})
|
|
raise
|
|
|
|
|
|
main()
|
|
'''
|
|
|
|
|
|
__all__ = [
|
|
"SCDM_EDIT_ADAPTER",
|
|
"SCDM_EDIT_SCHEMA_VERSION",
|
|
"generate_scdm_edit_script",
|
|
"prepare_scdm_edit_job",
|
|
"run_prepared_scdm_edit_job",
|
|
"run_scdm_edit_job",
|
|
]
|