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)