139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""Public FreeCAD translator entrypoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from typing import Any, Optional
|
|
|
|
from ...errors import raise_harness_error
|
|
from .script_translator import FreeCADScriptTranslator
|
|
|
|
|
|
def _json_ascii(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _discover_freecad_executable() -> Optional[str]:
|
|
candidates = [
|
|
shutil.which("FreeCADCmd"),
|
|
shutil.which("freecadcmd"),
|
|
shutil.which("FreeCAD"),
|
|
"/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd",
|
|
"/Applications/FreeCAD.app/Contents/MacOS/FreeCAD",
|
|
]
|
|
for candidate in candidates:
|
|
if candidate and os.path.exists(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def translate_model_json_to_freecad_script(
|
|
json_str: str,
|
|
document_name: str = "SimpleCADModel",
|
|
) -> str:
|
|
"""Translate exported model JSON into a FreeCAD Python script.
|
|
|
|
Part/Assembly product nodes are emitted as editable FreeCAD document
|
|
structure: parts use `App::Part`, assemblies use native
|
|
`Assembly::AssemblyObject`, part components use `App::Link`, and
|
|
subassembly components use `Assembly::AssemblyLink` when the Assembly
|
|
workbench module is available.
|
|
"""
|
|
|
|
return FreeCADScriptTranslator(
|
|
document_name=document_name
|
|
).translate_model_json_to_script(json_str)
|
|
|
|
|
|
def translate_model_json_to_fcstd(
|
|
json_str: str,
|
|
output_path: str,
|
|
*,
|
|
document_name: str = "SimpleCADModel",
|
|
freecad_cmd: Optional[str] = None,
|
|
) -> str:
|
|
"""Translate canonical model JSON to `.FCStd` via FreeCADCmd/FreeCAD.
|
|
|
|
Functional sketch promotions are written as visible `Sketcher::SketchObject`
|
|
nodes with mapped/skipped constraint evidence. Exact B-spline edges are
|
|
exported to FreeCAD using `Part.BSplineCurve().buildFromPolesMultsKnots(...)`.
|
|
Safe single-use profile transforms such as section rotate/translate chains are
|
|
folded into the section object's placement so downstream `Part::Loft` receives
|
|
already-positioned sections instead of placement-bearing `App::Link` proxies.
|
|
Part/Assembly product nodes are written as editable FreeCAD assembly structure:
|
|
parts use `App::Part`, assemblies use native `Assembly::AssemblyObject`, part
|
|
components use `App::Link`, and nested assembly components use
|
|
`Assembly::AssemblyLink`. Explicit assembly-to-compound projections remain in
|
|
the document for geometry workflows but do not replace the visible assembly
|
|
tree.
|
|
"""
|
|
|
|
freecad_exe = freecad_cmd or _discover_freecad_executable()
|
|
if not freecad_exe:
|
|
raise_harness_error(
|
|
operation="translate_model_json_to_fcstd",
|
|
what_happened="Could not locate a FreeCAD command-line executable.",
|
|
possible_causes=[
|
|
"FreeCADCmd is not installed or not available on PATH.",
|
|
"Only the GUI app is installed and no CLI entrypoint is reachable.",
|
|
],
|
|
how_to_fix=[
|
|
"Install FreeCAD with FreeCADCmd, or pass freecad_cmd=... explicitly.",
|
|
"Make sure FreeCADCmd or FreeCAD is on PATH.",
|
|
],
|
|
error=FileNotFoundError("FreeCADCmd/FreeCAD not found"),
|
|
)
|
|
|
|
script = translate_model_json_to_freecad_script(
|
|
json_str, document_name=document_name
|
|
)
|
|
resolved_output_path = os.path.abspath(output_path)
|
|
save_tail = (
|
|
f"\nOUTPUT_PATH = {_json_ascii(resolved_output_path)}\n"
|
|
"_apply_result_visibility(RESULT_NODE_IDS)\n"
|
|
"_set_active_result_object(RESULT_NODE_IDS)\n"
|
|
"_save_fcstd_with_gui_visibility(OUTPUT_PATH)\n"
|
|
"print(OUTPUT_PATH)\n"
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix="_simplecad_freecad_export.py", delete=False
|
|
) as handle:
|
|
temp_script_path = handle.name
|
|
handle.write(script)
|
|
handle.write(save_tail)
|
|
|
|
try:
|
|
completed = subprocess.run(
|
|
[freecad_exe, temp_script_path],
|
|
check=True,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
if not os.path.exists(resolved_output_path) or os.path.getsize(resolved_output_path) <= 0:
|
|
raise RuntimeError(
|
|
"FreeCAD export completed without creating a non-empty .FCStd file. "
|
|
f"stderr={completed.stderr.strip()!r}"
|
|
)
|
|
return output_path
|
|
except Exception as e:
|
|
raise_harness_error(
|
|
operation="translate_model_json_to_fcstd",
|
|
what_happened="Failed to execute the generated FreeCAD export script.",
|
|
possible_causes=[
|
|
"FreeCADCmd started but the generated script hit an unsupported API call.",
|
|
"The output path is invalid or not writable.",
|
|
"The installed FreeCAD build lacks Part or Spreadsheet support needed by the translator.",
|
|
],
|
|
how_to_fix=[
|
|
"Inspect the generated script first with translate_model_json_to_freecad_script().",
|
|
"Use a writable .FCStd output path.",
|
|
"Run the same script manually inside a matching FreeCAD environment to isolate runtime differences.",
|
|
],
|
|
error=e,
|
|
)
|