Files
Web_FreeCAD_Bitbybit/scripts/freecad-cam-path-oracle.py

577 lines
24 KiB
Python

import hashlib
import json
import os
import sys
import gc
import FreeCAD as App
import Path
MARKER = "FREECAD_CAM_PATH_RESULT="
def emit_marker(value, status=0):
result_file = os.environ.get("FREECAD_CAM_PATH_RESULT_FILE", "")
marker_value = value
if result_file:
parent = os.path.dirname(result_file)
if parent:
os.makedirs(parent, exist_ok=True)
with open(result_file, "w", encoding="utf-8") as stream:
json.dump(value, stream, sort_keys=True, separators=(",", ":"))
stream.write("\n")
os._exit(status)
marker_value = value
payload = (MARKER + json.dumps(marker_value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
offset = 0
while offset < len(payload):
offset += os.write(sys.stdout.fileno(), payload[offset:])
os._exit(status)
def command_record(command, precision=9):
threshold = 0.5 * (10 ** -precision)
parameters = {}
for name, value in sorted(dict(command.Parameters).items()):
rounded = round(float(value), precision)
parameters[str(name)] = 0.0 if abs(rounded) < threshold else rounded
parameter_text = " ".join(
"{}{:0.{}f}".format(name, value, precision)
for name, value in parameters.items()
)
return {
"name": str(command.Name),
"parameters": parameters,
"gcode": (str(command.Name) + (" " + parameter_text if parameter_text else "")).strip(),
}
def path_record(obj):
path = obj.Path
bounds = path.BoundBox
return {
"name": obj.Name,
"label": obj.Label,
"typeId": obj.TypeId,
"pathPropertyType": obj.getTypeIdOfProperty("Path"),
"commandCount": len(path.Commands),
"commands": [command_record(command) for command in path.Commands],
"length": round(float(path.Length), 9),
"boundingBox": {
"min": [round(float(bounds.XMin), 9), round(float(bounds.YMin), 9), round(float(bounds.ZMin), 9)],
"max": [round(float(bounds.XMax), 9), round(float(bounds.YMax), 9), round(float(bounds.ZMax), 9)],
},
"oracleTag": str(getattr(obj, "OracleTag", "")),
}
def archive_record(path):
with open(path, "rb") as stream:
payload = stream.read()
return {
"path": path,
"byteLength": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
}
def load_path_objects(path):
document = App.openDocument(path)
try:
records = [path_record(document.getObject(name)) for name in ("NativePath", "PythonPath")]
return records
finally:
App.closeDocument(document.Name)
def verify_preserved_archive(path):
records = load_path_objects(path)
expected_native_count = int(os.environ.get("FREECAD_CAM_PATH_EXPECT_NATIVE_COUNT", "5"))
expected_python_count = int(os.environ.get("FREECAD_CAM_PATH_EXPECT_PYTHON_COUNT", "5"))
expected_native_last = os.environ.get("FREECAD_CAM_PATH_EXPECT_NATIVE_LAST", "")
expected_python_last = os.environ.get("FREECAD_CAM_PATH_EXPECT_PYTHON_LAST", "")
expected_counts = {"NativePath": expected_native_count, "PythonPath": expected_python_count}
result = {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1",
"mode": "web-preserved-native-reopen",
"archive": archive_record(path),
"objects": records,
"verified": all(
record["pathPropertyType"] == "Path::PropertyPath"
and record["commandCount"] == expected_counts[record["name"]]
and (record["name"] != "NativePath" or not expected_native_last or record["commands"][-1]["name"] == expected_native_last)
and (record["name"] != "PythonPath" or not expected_python_last or record["commands"][-1]["name"] == expected_python_last)
for record in records
),
}
emit_marker(result, 0 if result["verified"] else 2)
def qt_command_oracle(document, selected_object):
import FreeCADGui as Gui
from PySide import QtCore, QtWidgets
workbenches = Gui.listWorkbenches()
workbench_name = "CAMWorkbench" if "CAMWorkbench" in workbenches else "PathWorkbench"
if workbench_name not in workbenches:
raise RuntimeError("FreeCAD CAM workbench is unavailable in the desktop oracle")
Gui.activateWorkbench(workbench_name)
Gui.updateGui()
QtWidgets.QApplication.processEvents()
all_commands = sorted(set(Gui.listCommands()))
cam_commands = [command_id for command_id in all_commands if command_id.startswith("CAM_")]
required = [
"CAM_Job",
"CAM_Profile",
"CAM_Pocket_Shape",
"CAM_Drilling",
"CAM_Adaptive",
"CAM_Pocket3D",
"CAM_Post",
"CAM_Simulator",
"CAM_Sanity",
"CAM_ToolBitDock",
]
def observe(command_id):
command = Gui.Command.get(command_id)
actions = [] if command is None else list(command.getAction())
if not actions:
return {"registered": command is not None, "actionCount": 0, "enabled": None}
action = actions[0]
return {
"registered": True,
"actionCount": len(actions),
"objectName": str(action.objectName()),
"text": str(action.text()).replace("&", ""),
"toolTip": str(action.toolTip()),
"shortcut": str(action.shortcut().toString()),
"enabled": bool(command.isActive()),
"visible": bool(action.isVisible()),
"iconPresent": not action.icon().isNull(),
}
Gui.Selection.clearSelection()
Gui.Command.update()
Gui.updateGui()
QtWidgets.QApplication.processEvents()
empty = {command_id: observe(command_id) for command_id in required}
Gui.Selection.addSelection(document.Name, selected_object.Name)
Gui.Command.update()
Gui.updateGui()
QtWidgets.QApplication.processEvents()
selected = {command_id: observe(command_id) for command_id in required}
Gui.Selection.clearSelection()
selected_job_object = document.getObject("OracleJob")
Gui.Selection.addSelection(document.Name, selected_job_object.Name)
Gui.Command.update()
Gui.updateGui()
QtWidgets.QApplication.processEvents()
selected_job = {command_id: observe(command_id) for command_id in required}
Gui.Selection.clearSelection()
selected_operation_object = document.getObject("OracleProfile")
Gui.Selection.addSelection(document.Name, selected_operation_object.Name)
Gui.Command.update()
Gui.updateGui()
QtWidgets.QApplication.processEvents()
selected_operation = {command_id: observe(command_id) for command_id in required}
Gui.Selection.clearSelection()
empty_document = App.newDocument("CamQtEmptyContext")
Gui.Command.update()
Gui.updateGui()
QtWidgets.QApplication.processEvents()
empty_document_state = {command_id: observe(command_id) for command_id in required}
App.closeDocument(empty_document.Name)
App.setActiveDocument(document.Name)
Gui.Command.update()
Gui.updateGui()
main_window = Gui.getMainWindow()
toolbars = []
for toolbar in main_window.findChildren(QtWidgets.QToolBar):
command_ids = [str(action.objectName()) for action in toolbar.actions() if str(action.objectName()).startswith("CAM_")]
if command_ids:
toolbars.append({
"objectName": str(toolbar.objectName()),
"title": str(toolbar.windowTitle()).replace("&", ""),
"visible": bool(toolbar.isVisible()),
"commands": command_ids,
})
menus = []
for menu_action in main_window.menuBar().actions():
menu = menu_action.menu()
if menu is None:
continue
command_ids = []
for action in menu.actions():
action_name = str(action.objectName())
if action_name.startswith("CAM_"):
command_ids.append(action_name)
submenu = action.menu()
if submenu is not None:
command_ids.extend(
str(child.objectName())
for child in submenu.actions()
if str(child.objectName()).startswith("CAM_")
)
if command_ids:
menus.append({
"title": str(menu_action.text()).replace("&", ""),
"commands": sorted(set(command_ids)),
})
def flush_qt():
Gui.updateGui()
QtWidgets.QApplication.processEvents()
def task_panel_state(phase):
focus = QtWidgets.QApplication.focusWidget()
button_boxes = [box for box in main_window.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()]
buttons = []
for box in button_boxes:
for button in box.buttons():
buttons.append({
"text": str(button.text()).replace("&", ""),
"objectName": str(button.objectName()),
"enabled": bool(button.isEnabled()),
"visible": bool(button.isVisible()),
"default": bool(button.isDefault()),
"role": str(box.buttonRole(button)),
})
fields = []
field_types = (QtWidgets.QLineEdit, QtWidgets.QComboBox, QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox, QtWidgets.QCheckBox)
widgets = []
seen_widgets = set()
for field_type in field_types:
for widget in main_window.findChildren(field_type):
identity = id(widget)
if identity not in seen_widgets:
widgets.append(widget)
seen_widgets.add(identity)
for widget in widgets:
if not widget.isVisible():
continue
fields.append({
"className": str(widget.metaObject().className()),
"objectName": str(widget.objectName()),
"enabled": bool(widget.isEnabled()),
"focus": widget is focus,
})
field_classes = sorted({field["className"] for field in fields})
in_edit = Gui.activeDocument().getInEdit() if Gui.activeDocument() else None
in_edit_object = getattr(in_edit, "Object", None) if in_edit else None
return {
"phase": phase,
"activeDialog": bool(Gui.Control.activeDialog()),
"inEdit": str(getattr(in_edit_object, "Name", "")) if in_edit else "",
"focus": None if focus is None else {"className": str(focus.metaObject().className()), "objectName": str(focus.objectName())},
"buttonBoxCount": len(button_boxes),
"buttonTexts": [{"text": button["text"], "enabled": button["enabled"], "default": button["default"], "role": button["role"]} for button in buttons],
"fieldCount": len(fields),
"fieldClasses": field_classes,
}
def close_task_panel(action):
candidates = []
for box in main_window.findChildren(QtWidgets.QDialogButtonBox):
if not box.isVisible():
continue
for button in box.buttons():
role = box.buttonRole(button)
if action == "accept" and role in (QtWidgets.QDialogButtonBox.AcceptRole, QtWidgets.QDialogButtonBox.ApplyRole):
candidates.append(button)
if action == "cancel" and role in (QtWidgets.QDialogButtonBox.RejectRole, QtWidgets.QDialogButtonBox.DestructiveRole):
candidates.append(button)
clicked = False
if candidates:
candidates[0].click()
clicked = True
flush_qt()
if Gui.Control.activeDialog():
Gui.activeDocument().resetEdit()
flush_qt()
return clicked
def edit_lifecycle(object_name, action):
before = task_panel_state("before")
entered = bool(Gui.activeDocument().setEdit(object_name))
flush_qt()
active = task_panel_state("active")
clicked = close_task_panel(action)
closed = task_panel_state("closed")
return {
"objectName": object_name,
"action": action,
"setEditAccepted": entered,
"buttonClicked": clicked,
"before": before,
"active": active,
"closed": closed,
"dialogOpened": active["activeDialog"] and bool(active["inEdit"]),
"dialogClosed": not closed["activeDialog"] and not closed["inEdit"],
}
task_lifecycles = [
edit_lifecycle(selected_job_object.Name, "cancel"),
edit_lifecycle(selected_operation_object.Name, "accept"),
edit_lifecycle(selected_operation_object.Name, "cancel"),
]
gc.collect()
QtWidgets.QApplication.processEvents()
observations = []
for command_id in required:
observations.append({
"id": command_id,
"emptySelection": empty[command_id],
"selectedModel": selected[command_id],
"selectedJob": selected_job[command_id],
"selectedOperation": selected_operation[command_id],
"emptyDocument": empty_document_state[command_id],
"selectionChangesEnabledState": len({
(empty[command_id].get("enabled"), empty[command_id].get("isActive")),
(selected[command_id].get("enabled"), selected[command_id].get("isActive")),
(selected_job[command_id].get("enabled"), selected_job[command_id].get("isActive")),
(selected_operation[command_id].get("enabled"), selected_operation[command_id].get("isActive")),
(empty_document_state[command_id].get("enabled"), empty_document_state[command_id].get("isActive")),
}) > 1,
})
return {
"guiUp": bool(App.GuiUp),
"qtVersion": str(QtCore.qVersion()),
"workbench": workbench_name,
"activeWorkbench": str(Gui.activeWorkbench().name()),
"registeredCommandCount": len(all_commands),
"camCommandCount": len(cam_commands),
"camCommands": cam_commands,
"requiredCommands": observations,
"toolbarCount": len(toolbars),
"toolbars": sorted(toolbars, key=lambda item: (item["title"], item["objectName"])),
"menuCount": len(menus),
"menus": menus,
"taskLifecycles": task_lifecycles,
"taskLifecycleVerified": all(item["dialogOpened"] and item["dialogClosed"] for item in task_lifecycles),
}
def profile_algorithm_oracle():
import Path.Main.Job as PathJob
import Path.Op.Profile as PathProfile
fixture = os.path.join(App.getHomePath(), "Mod", "CAM", "CAMTests", "test_profile.fcstd")
App.ConfigSet("SuppressRecomputeRequiredDialog", "True")
document = App.openDocument(fixture)
App.ConfigSet("SuppressRecomputeRequiredDialog", "")
job = PathJob.Create("OracleJob", [document.Body], None)
job.GeometryTolerance.Value = 0.001
profile = PathProfile.Create("OracleProfile", parentJob=job)
profile.Base = [(document.Body, ["Face18"])]
profile.processCircles = True
profile.processHoles = True
profile.UseComp = True
profile.Direction = "CW"
document.recompute()
def summarize(operation, scenario_id):
commands = [command_record(command, 2) for command in operation.Path.Commands]
cutting = [command for command in commands if command["name"] in ("G1", "G2", "G3")]
canonical = json.dumps(commands, sort_keys=True, separators=(",", ":"))
return {
"id": scenario_id,
"useComp": bool(operation.UseComp),
"direction": str(operation.Direction),
"commandCount": len(commands),
"cuttingCommandCount": len(cutting),
"commandSha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
"firstCuttingCommand": cutting[0] if cutting else None,
"lastCuttingCommand": cutting[-1] if cutting else None,
"pathLength": round(float(operation.Path.Length), 2),
"commands": commands,
}
compensated = summarize(profile, "outside-cw-tool-comp")
no_comp = PathProfile.Create("OracleProfileNoComp", parentJob=job)
no_comp.Base = [(document.Body, ["Face18"])]
no_comp.processCircles = True
no_comp.processHoles = True
no_comp.UseComp = False
no_comp.Direction = "CW"
document.recompute()
uncompensated = summarize(no_comp, "outside-cw-no-comp")
result = {
"fixture": "Mod/CAM/CAMTests/test_profile.fcstd",
"fixtureSha256": archive_record(fixture)["sha256"],
"modelTypeId": document.Body.TypeId,
"jobTypeId": job.TypeId,
"operationTypeId": profile.TypeId,
"pathPropertyType": profile.getTypeIdOfProperty("Path"),
"commandCount": compensated["commandCount"],
"cuttingCommandCount": compensated["cuttingCommandCount"],
"commandSha256": compensated["commandSha256"],
"normalizationToleranceMm": 0.01,
"firstCuttingCommand": compensated["firstCuttingCommand"],
"lastCuttingCommand": compensated["lastCuttingCommand"],
"pathLength": compensated["pathLength"],
"commands": compensated["commands"],
"variants": [compensated, uncompensated],
}
return document, result
def helix_algorithm_oracle():
import Path.Main.Job as PathJob
import Path.Op.Helix as PathHelix
fixture = os.path.join(App.getHomePath(), "Mod", "CAM", "CAMTests", "test_holes00.fcstd")
App.ConfigSet("SuppressRecomputeRequiredDialog", "True")
document = App.openDocument(fixture)
App.ConfigSet("SuppressRecomputeRequiredDialog", "")
try:
job = PathJob.Create("HelixOracleJob", [document.Body], None)
job.Tools.Group[0].Tool.Diameter = 0.9
operation = PathHelix.Create("OracleHelix", parentJob=job)
scenarios = []
expected = [
("inside-conventional", "Inside", "Conventional", "CW", "G2"),
("outside-climb", "Outside", "Climb", "CW", "G2"),
("inside-climb", "Inside", "Climb", "CCW", "G3"),
("outside-conventional", "Outside", "Conventional", "CCW", "G3"),
]
for scenario_id, start_side, cut_mode, expected_direction, expected_arc in expected:
operation.StartSide = start_side
operation.CutMode = cut_mode
operation.enforceRecompute()
document.recompute()
commands = [command_record(command, 2) for command in operation.Path.Commands]
arc_commands = [command for command in commands if command["name"] in ("G2", "G3")]
arc_names = sorted({command["name"] for command in arc_commands})
command_histogram = {
name: sum(1 for command in commands if command["name"] == name)
for name in sorted({command["name"] for command in commands})
}
canonical = json.dumps(commands, sort_keys=True, separators=(",", ":"))
scenarios.append({
"id": scenario_id,
"startSide": str(operation.StartSide),
"cutMode": str(operation.CutMode),
"direction": str(operation.Direction),
"expectedDirection": expected_direction,
"expectedArc": expected_arc,
"arcNames": arc_names,
"arcCommandCount": len(arc_commands),
"firstArcCommand": arc_commands[0] if arc_commands else None,
"lastArcCommand": arc_commands[-1] if arc_commands else None,
"commandCount": len(commands),
"commandHistogram": command_histogram,
"commandSha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
"pathLength": round(float(operation.Path.Length), 2),
})
base_sub_elements = sum(len(base[1]) for base in operation.Base)
return {
"fixture": "Mod/CAM/CAMTests/test_holes00.fcstd",
"fixtureSha256": archive_record(fixture)["sha256"],
"modelTypeId": document.Body.TypeId,
"jobTypeId": job.TypeId,
"operationTypeId": operation.TypeId,
"pathPropertyType": operation.getTypeIdOfProperty("Path"),
"toolDiameterMm": round(float(job.Tools.Group[0].Tool.Diameter), 3),
"baseSubElementCount": base_sub_elements,
"normalizationToleranceMm": 0.01,
"scenarioCount": len(scenarios),
"scenarios": scenarios,
}
finally:
App.closeDocument(document.Name)
def create_roundtrip_archives(source_path, mutated_path):
document = App.newDocument("NativeCamPathRoundTrip")
native = document.addObject("Path::Feature", "NativePath")
native.Label = "Native Path Original"
native.addProperty("App::PropertyString", "OracleTag", "Oracle")
native.OracleTag = "native-v1"
python_path = document.addObject("Path::FeaturePython", "PythonPath")
python_path.Label = "Python Path Original"
python_path.addProperty("App::PropertyString", "OracleTag", "Oracle")
python_path.OracleTag = "python-v1"
commands = [
Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}),
Path.Command("G1", {"X": 0.0, "Y": 0.0, "Z": 0.0, "F": 120.0}),
Path.Command("G1", {"X": 20.0, "Y": 0.0, "Z": 0.0, "F": 300.0}),
Path.Command("G2", {"X": 20.0, "Y": 20.0, "I": 0.0, "J": 10.0, "F": 300.0}),
Path.Command("G0", {"X": 20.0, "Y": 20.0, "Z": 5.0}),
]
native.Path = Path.Path(commands)
python_path.Path = Path.Path([command for command in commands])
document.recompute()
document.saveAs(source_path)
source_before_close = [path_record(native), path_record(python_path)]
App.closeDocument(document.Name)
source_reopened = load_path_objects(source_path)
mutated = App.openDocument(source_path)
native_mutated = mutated.getObject("NativePath")
python_mutated = mutated.getObject("PythonPath")
native_mutated.Label = "Native Path Mutated"
native_mutated.OracleTag = "native-v2"
native_mutated.Path = Path.Path(native_mutated.Path.Commands + [Path.Command("M5")])
python_mutated.Label = "Python Path Mutated"
python_mutated.OracleTag = "python-v2"
python_mutated.Path = Path.Path(python_mutated.Path.Commands + [Path.Command("M2")])
mutated.recompute()
mutated.saveAs(mutated_path)
App.closeDocument(mutated.Name)
mutated_reopened = load_path_objects(mutated_path)
return {
"source": archive_record(source_path),
"mutated": archive_record(mutated_path),
"sourceBeforeClose": source_before_close,
"sourceReopened": source_reopened,
"mutatedReopened": mutated_reopened,
}
preserved_path = os.environ.get("FREECAD_CAM_PATH_VERIFY_ARCHIVE", "")
if os.environ.get("FREECAD_CAM_PATH_VERIFY_ONLY") == "1":
if not preserved_path:
raise RuntimeError("FREECAD_CAM_PATH_VERIFY_ARCHIVE is required in verify-only mode")
verify_preserved_archive(preserved_path)
source_path = os.environ.get("FREECAD_CAM_PATH_SOURCE", "")
mutated_path = os.environ.get("FREECAD_CAM_PATH_MUTATED", "")
if not source_path or not mutated_path:
raise RuntimeError("FREECAD_CAM_PATH_SOURCE and FREECAD_CAM_PATH_MUTATED are required")
profile_document, profile_result = profile_algorithm_oracle()
helix_result = helix_algorithm_oracle()
App.setActiveDocument(profile_document.Name)
try:
qt_result = qt_command_oracle(profile_document, profile_document.Body)
finally:
App.closeDocument(profile_document.Name)
fcstd_result = create_roundtrip_archives(source_path, mutated_path)
version = App.Version()
result = {
"schemaVersion": 1,
"baselineId": "freecad-1.1.1",
"freecadVersion": ".".join(str(value) for value in version[:3]),
"revision": str(version[3]),
"gitCommit": str(version[7]) if len(version) > 7 else "",
"nativeRuntime": {
"guiUp": bool(App.GuiUp),
"pathModule": str(getattr(Path, "__file__", "built-in")),
},
"profileAlgorithm": profile_result,
"helixAlgorithm": helix_result,
"qtDynamicOracle": qt_result,
"fcstdRoundTrip": fcstd_result,
}
emit_marker(result)