zcbot/windows-node/adapters/freecad.model.author@v1/freecad_worker.py

354 lines
12 KiB
Python

"""Fixed FreeCAD-side model compiler. FreeCADCmd may import this file directly."""
from __future__ import annotations
import hashlib
import json
import os
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import FreeCAD as App
import Import
import Part
MAX_SOLIDS = 256
MAX_TRIANGLES = 500_000
JOB_ENV = "ZCBOT_FREECAD_JOB_DIR"
class EngineError(RuntimeError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(detail or code)
self.code = code
def _atomic_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(
value,
handle,
ensure_ascii=False,
indent=2,
sort_keys=True,
allow_nan=False,
)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _checked(shape: object, role: str):
if shape is None or shape.isNull() or not shape.isValid():
raise EngineError("FREECAD_SHAPE_INVALID", role)
solids = list(shape.Solids)
if not solids:
raise EngineError("FREECAD_SHAPE_HAS_NO_SOLIDS", role)
if len(solids) > MAX_SOLIDS:
raise EngineError("FREECAD_ENTITY_LIMIT_EXCEEDED", role)
return shape
def _step_shape(path: Path):
document = App.newDocument("ZcbotStepImport")
try:
Import.insert(str(path), document.Name)
if document.recompute() is False:
raise EngineError("FREECAD_STEP_RECOMPUTE_FAILED")
shapes = [
obj.Shape.copy()
for obj in document.Objects
if hasattr(obj, "Shape") and not obj.Shape.isNull()
]
if not shapes:
raise EngineError("FREECAD_STEP_IMPORT_EMPTY")
shape = shapes[0] if len(shapes) == 1 else Part.makeCompound(shapes)
except Exception as exc:
if isinstance(exc, EngineError):
raise
raise EngineError("FREECAD_STEP_IMPORT_FAILED", str(exc)) from exc
finally:
App.closeDocument(document.Name)
return _checked(shape, "STEP input")
def _make(step: dict[str, object], values: dict[str, object], input_path: Path | None):
op = step["op"]
if op == "box":
shape = Part.makeBox(step["length"], step["width"], step["height"])
elif op == "cylinder":
shape = Part.makeCylinder(
step["radius"],
step["height"],
App.Vector(0, 0, 1),
step.get("angle_degrees", 360.0),
)
elif op == "cone":
shape = Part.makeCone(
step["radius1"],
step["radius2"],
step["height"],
App.Vector(0, 0, 1),
step.get("angle_degrees", 360.0),
)
elif op == "sphere":
shape = Part.makeSphere(
step["radius"],
App.Vector(),
App.Vector(0, 0, 1),
step.get("angle1_degrees", -90.0),
step.get("angle2_degrees", 90.0),
step.get("angle3_degrees", 360.0),
)
elif op == "torus":
shape = Part.makeTorus(
step["major_radius"],
step["minor_radius"],
App.Vector(),
App.Vector(0, 0, 1),
step.get("angle1_degrees", -180.0),
step.get("angle2_degrees", 180.0),
step.get("angle3_degrees", 360.0),
)
elif op == "import_step":
if input_path is None:
raise EngineError("FREECAD_STEP_INPUT_NOT_BOUND")
shape = _step_shape(input_path)
elif op in {"union", "cut", "intersect"}:
shape = values[step["target"]]
for name in step["tools"]:
tool = values[name]
if op == "union":
shape = shape.fuse(tool)
elif op == "cut":
shape = shape.cut(tool)
else:
shape = shape.common(tool)
elif op == "translate":
shape = values[step["target"]].copy()
shape.translate(App.Vector(*step["vector"]))
elif op == "rotate":
shape = values[step["target"]].copy()
shape.rotate(
App.Vector(*step["axis_point"]),
App.Vector(*step["axis_direction"]),
step["angle_degrees"],
)
elif op == "combine":
shape = Part.makeCompound([values[name] for name in step["targets"]])
else:
raise EngineError("FREECAD_OPERATION_UNSUPPORTED", str(op))
return _checked(shape, str(step["id"]))
def _metrics(shape: object) -> dict[str, object]:
box = shape.BoundBox
return {
"solids": len(shape.Solids),
"bounding_box": {
"min": [box.XMin, box.YMin, box.ZMin],
"max": [box.XMax, box.YMax, box.ZMax],
"size": [box.XLength, box.YLength, box.ZLength],
},
"volume": float(shape.Volume),
"area": float(shape.Area),
"center_of_mass": [
shape.CenterOfMass.x,
shape.CenterOfMass.y,
shape.CenterOfMass.z,
],
}
def _compare(expected: dict[str, object], actual: dict[str, object], role: str) -> None:
if expected["solids"] != actual["solids"]:
raise EngineError(f"FREECAD_{role}_SOLID_COUNT_MISMATCH")
expected_box = expected["bounding_box"]
actual_box = actual["bounding_box"]
scale = max([1.0, *expected_box["size"]])
box_tolerance = scale * 1e-7
for key in ("min", "max", "size"):
if any(
abs(a - b) > box_tolerance
for a, b in zip(expected_box[key], actual_box[key])
):
raise EngineError(f"FREECAD_{role}_BOUNDING_BOX_MISMATCH")
volume_tolerance = max(1e-8, abs(expected["volume"]) * 1e-7)
if abs(expected["volume"] - actual["volume"]) > volume_tolerance:
raise EngineError(f"FREECAD_{role}_VOLUME_MISMATCH")
def _mesh(shape: object) -> dict[str, object]:
box = shape.BoundBox
deflection = max(box.DiagonalLength * 0.002, 0.01)
vertices, triangles = shape.tessellate(deflection)
if not vertices or not triangles or len(triangles) > MAX_TRIANGLES:
raise EngineError("FREECAD_PREVIEW_LIMIT_EXCEEDED")
return {
"vertices": [
[float(vertex.x), float(vertex.y), float(vertex.z)] for vertex in vertices
],
"triangles": [[int(index) for index in triangle] for triangle in triangles],
}
def _surface_types(shape: object) -> dict[str, int]:
names = []
for face in shape.Faces:
names.append(type(face.Surface).__name__.removeprefix("Surface").casefold())
return dict(sorted(Counter(names).items()))
def run(job_dir: Path) -> None:
output = (job_dir / "output").resolve(strict=True)
metadata = (output / ".meta").resolve(strict=True)
if not output.is_relative_to(job_dir) or not metadata.is_relative_to(output):
raise EngineError("FREECAD_OUTPUT_PATH_ESCAPES_JOB")
payload = json.loads((metadata / "resolved-job.json").read_text(encoding="utf-8"))
model = payload["model"]
input_path = (
Path(payload["input_path"]).resolve(strict=True)
if payload.get("input_path")
else None
)
if input_path is not None and not input_path.is_relative_to(
(job_dir / "input").resolve(strict=True)
):
raise EngineError("FREECAD_INPUT_PATH_ESCAPES_JOB")
document = App.newDocument("ZcbotModel")
values: dict[str, object] = {}
objects: dict[str, object] = {}
try:
for step in model["steps"]:
shape = _make(step, values, input_path)
values[step["id"]] = shape
obj = document.addObject("Part::Feature", step["id"])
obj.Label = step["id"]
obj.Shape = shape
objects[step["id"]] = obj
result_shape = _checked(values[model["result"]], "result")
result_object_name = objects[model["result"]].Name
if document.recompute() is False:
raise EngineError("FREECAD_DOCUMENT_RECOMPUTE_FAILED")
for obj in document.Objects:
if (
hasattr(obj, "Shape")
and not obj.Shape.isNull()
and not obj.Shape.isValid()
):
raise EngineError("FREECAD_DOCUMENT_SHAPE_INVALID", obj.Name)
project_path = output / "model.FCStd"
document.saveAs(str(project_path))
baseline = _metrics(result_shape)
finally:
App.closeDocument(document.Name)
reopened = App.openDocument(str(project_path))
try:
if reopened.recompute() is False:
raise EngineError("FREECAD_FCSTD_RECOMPUTE_FAILED")
result_object = reopened.getObject(result_object_name)
if result_object is None:
raise EngineError("FREECAD_FCSTD_RESULT_MISSING")
fcstd_shape = _checked(result_object.Shape, "FCStd roundtrip")
_compare(baseline, _metrics(fcstd_shape), "FCSTD_ROUNDTRIP")
step_path = output / "model.step"
fcstd_shape.exportStep(str(step_path))
if not step_path.is_file() or step_path.stat().st_size < 256:
raise EngineError("FREECAD_STEP_EXPORT_EMPTY")
finally:
App.closeDocument(reopened.Name)
step_shape = _step_shape(step_path)
step_metrics = _metrics(step_shape)
_compare(baseline, step_metrics, "STEP_ROUNDTRIP")
_atomic_json(
output / "model-recipe.json",
{
"schema_version": 1,
"recipe": payload["recipe"],
"resolved_model": model,
},
)
_atomic_json(
output / "model-manifest.json",
{
"schema_version": 1,
"title": model["title"],
"unit": "mm",
"valid": True,
"result": model["result"],
"step_count": len(model["steps"]),
"operation_counts": dict(
sorted(Counter(step["op"] for step in model["steps"]).items())
),
"geometry": step_metrics,
"surface_types": _surface_types(step_shape),
"roundtrip": {"fcstd": "verified", "step": "verified"},
"input": None
if input_path is None
else {
"filename": input_path.name,
"size_bytes": input_path.stat().st_size,
"sha256": _sha256(input_path),
},
},
)
_atomic_json(metadata / "preview-mesh.json", _mesh(step_shape))
_atomic_json(
metadata / "provenance.json",
{
"adapter_version": payload["adapter_version"],
"freecad_version": payload["software_version"],
"request_digest": payload.get("request_digest"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"network_access": False,
"gui_started": False,
"safe_mode": True,
"fcstd_sha256": _sha256(project_path),
"step_sha256": _sha256(step_path),
},
)
def _entry() -> None:
raw_job_dir = os.environ.get(JOB_ENV)
if not raw_job_dir:
return
job_dir = Path(raw_job_dir).resolve(strict=True)
result_path = job_dir / "output" / ".meta" / "engine-result.json"
try:
run(job_dir)
_atomic_json(result_path, {"status": "succeeded"})
except Exception as exc: # noqa: BLE001 - fixed engine boundary must report all failures
code = exc.code if isinstance(exc, EngineError) else "FREECAD_ENGINE_FAILED"
_atomic_json(
result_path,
{
"status": "failed",
"code": code,
"detail": f"{type(exc).__name__}: {exc}"[:500],
},
)
print(f"[ERR] {code}: {exc}", file=sys.stderr)
# FreeCADCmd can execute Python files by importing them, so do not rely on __main__.
_entry()