import hashlib import json import math import os import FreeCAD as App import Part FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" OUTPUT_PATH = os.environ.get("FREECAD_PROPERTY_DIRECTION_FAILURE_OUTPUT", "") PROBE_MODE = os.environ.get("FREECAD_PROPERTY_DIRECTION_FAILURE_MODE", "main") def component_value(value): number = float(value) if math.isnan(number): return "NaN" if math.isinf(number): return "Infinity" if number > 0 else "-Infinity" return number def vector_value(obj, property_name): value = getattr(obj, property_name) return [component_value(value.x), component_value(value.y), component_value(value.z)] def object_set(document): return [{"name": obj.Name, "typeId": obj.TypeId} for obj in document.Objects] def exception_snapshot(callback): try: callback() except Exception as error: return {"type": type(error).__name__, "message": str(error)} return None def active_transaction_snapshot(): active = App.getActiveTransaction() if not active: return {"name": "", "id": 0} return {"name": str(active[0]), "id": int(active[1])} def state_snapshot(obj): return { "state": [str(item) for item in obj.State], "statusString": str(obj.getStatusString()), "mustExecute": bool(obj.MustExecute), } def shape_snapshot(obj): shape = obj.Shape if shape.isNull(): return { "isNull": True, "shapeType": "Null", "valid": False, "solids": 0, "faces": 0, "edges": 0, "vertices": 0, } return { "isNull": False, "shapeType": shape.ShapeType, "valid": bool(shape.isValid()), "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "vertices": len(shape.Vertexes), "brepSha256": hashlib.sha256(shape.exportBrepToString().encode("utf-8")).hexdigest(), } def setup_mirroring(document): source = document.addObject("Part::Box", "MirrorSource") source.Length = 2 source.Width = 3 source.Height = 4 host = document.addObject("Part::Mirroring", "DirectionMirror") host.Source = source host.Base = App.Vector(0, 0, 0) return host, "Normal" def setup_projection(document): support = document.addObject("Part::Feature", "ProjectionSupport") support.Shape = Part.makePlane(40, 40, App.Vector(-20, -20, 0)) wire = document.addObject("Part::Feature", "ProjectionWire") wire.Shape = Part.makePolygon([ App.Vector(-4, -3, -5), App.Vector(4, -3, -5), App.Vector(4, 3, -5), App.Vector(-4, 3, -5), App.Vector(-4, -3, -5), ]) host = document.addObject("Part::ProjectOnSurface", "DirectionProjection") host.SupportFace = (support, ["Face1"]) host.Projection = [(wire, ["Wire1"])] host.Mode = "Edges" return host, "Direction" def run_setter_boundaries(): document = App.newDocument("PropertyDirectionSetterFailure") try: obj, property_name = setup_mirroring(document) document.recompute() baseline = vector_value(obj, property_name) initial_objects = object_set(document) accepted = [] for case_id, requested in [ ("mixed-numeric-tuple", (1, 0.5, 2)), ("boolean-tuple", (True, False, True)), ]: before = vector_value(obj, property_name) before_objects = object_set(document) exception = exception_snapshot(lambda value=requested: setattr(obj, property_name, value)) after = vector_value(obj, property_name) after_objects = object_set(document) accepted.append({ "id": case_id, "requested": repr(requested), "exception": exception, "accepted": exception is None, "before": before, "after": after, "objectsPreserved": before_objects == after_objects, }) setattr(obj, property_name, tuple(baseline)) failures = [] for case_id, requested in [ ("none", None), ("list", [0.0, 0.0, 1.0]), ("short-tuple", (0.0, 1.0)), ("long-tuple", (0.0, 0.0, 1.0, 2.0)), ("string-component", (0.0, "bad", 1.0)), ("scalar-float", 1.0), ("scalar-integer", 1), ]: before = vector_value(obj, property_name) before_objects = object_set(document) exception = exception_snapshot(lambda value=requested: setattr(obj, property_name, value)) after = vector_value(obj, property_name) after_objects = object_set(document) failures.append({ "id": case_id, "requested": repr(requested), "exception": exception, "before": before, "after": after, "valuePreserved": before == after, "beforeObjects": before_objects, "afterObjects": after_objects, "objectsPreserved": before_objects == after_objects, "polluted": before_objects != after_objects, }) if exception is None: setattr(obj, property_name, tuple(baseline)) return { "object": {"name": obj.Name, "typeId": obj.TypeId}, "property": {"name": property_name, "typeId": obj.getTypeIdOfProperty(property_name)}, "baseline": baseline, "acceptedCoercions": accepted, "failures": failures, "documentIntegrity": { "initialObjects": initial_objects, "finalObjects": object_set(document), "objectsPreserved": initial_objects == object_set(document), }, } finally: App.closeDocument(document.Name) def run_consumer_direction(case_id, object_type_id, setup, requested): document = App.newDocument("PropertyDirectionConsumer" + case_id) try: obj, property_name = setup(document) initial_recompute = bool(document.recompute()) before = { "value": vector_value(obj, property_name), "state": state_snapshot(obj), "shape": shape_snapshot(obj), "objects": object_set(document), } setter_exception = exception_snapshot(lambda: setattr(obj, property_name, requested)) after_set = { "value": vector_value(obj, property_name), "state": state_snapshot(obj), "shape": shape_snapshot(obj), } recompute_exception = None recompute_result = None try: recompute_result = bool(document.recompute()) except Exception as error: recompute_exception = {"type": type(error).__name__, "message": str(error)} after_recompute = { "value": vector_value(obj, property_name), "state": state_snapshot(obj), "shape": shape_snapshot(obj), "objects": object_set(document), } return { "id": case_id, "objectTypeId": object_type_id, "propertyName": property_name, "requested": [component_value(value) for value in requested], "initialRecomputeResult": initial_recompute, "setterException": setter_exception, "recomputeException": recompute_exception, "recomputeResult": recompute_result, "before": before, "afterSet": after_set, "afterRecompute": after_recompute, "objectsPreserved": before["objects"] == after_recompute["objects"], } finally: App.closeDocument(document.Name) def run_missing_dependency(case_id, object_type_id, setup, remove_dependency): document = App.newDocument("PropertyDirectionDependency" + case_id) try: obj, property_name = setup(document) document.recompute() before = { "value": vector_value(obj, property_name), "state": state_snapshot(obj), "shape": shape_snapshot(obj), "objects": object_set(document), } removed = remove_dependency(obj) recompute_exception = None recompute_result = None try: recompute_result = bool(document.recompute()) except Exception as error: recompute_exception = {"type": type(error).__name__, "message": str(error)} after = { "value": vector_value(obj, property_name), "state": state_snapshot(obj), "shape": shape_snapshot(obj), "objects": object_set(document), } return { "id": case_id, "objectTypeId": object_type_id, "propertyName": property_name, "removed": removed, "recomputeException": recompute_exception, "recomputeResult": recompute_result, "before": before, "afterRecompute": after, "objectsPreserved": before["objects"] == after["objects"], } finally: App.closeDocument(document.Name) def run_disabled_and_transaction(): document = App.newDocument("PropertyDirectionDisabledAndTransaction") try: obj, property_name = setup_mirroring(document) document.recompute() document.UndoMode = 1 baseline = vector_value(obj, property_name) initial_objects = object_set(document) obj.setEditorMode(property_name, ["ReadOnly"]) editor_mode = [str(item) for item in obj.getEditorMode(property_name)] editor_exception = exception_snapshot(lambda: setattr(obj, property_name, (1.0, 0.0, 1.0))) editor_after = vector_value(obj, property_name) setattr(obj, property_name, tuple(baseline)) obj.setPropertyStatus(property_name, "Immutable") immutable_status = [str(item) for item in obj.getPropertyStatus(property_name)] immutable_exception = exception_snapshot(lambda: setattr(obj, property_name, (0.0, 1.0, 1.0))) immutable_after = vector_value(obj, property_name) obj.setPropertyStatus(property_name, "-Immutable") obj.setEditorMode(property_name, 0) disabled = { "classification": "editor-readonly-python-mutable-until-immutable", "before": baseline, "editorMode": editor_mode, "editorRequested": [1.0, 0.0, 1.0], "editorException": editor_exception, "editorAfter": editor_after, "pythonBypassesEditorReadOnly": editor_exception is None, "immutableStatus": immutable_status, "immutableRequested": [0.0, 1.0, 1.0], "immutableException": immutable_exception, "immutableAfter": immutable_after, "immutableValuePreserved": immutable_after == baseline, "restoredStatus": [str(item) for item in obj.getPropertyStatus(property_name)], "restoredEditorMode": [str(item) for item in obj.getEditorMode(property_name)], } document.recompute() document.openTransaction("property-direction-cancel") transaction_before = vector_value(obj, property_name) transaction_objects_before = object_set(document) setattr(obj, property_name, (0.25, -0.5, 1.5)) transaction_edited = vector_value(obj, property_name) state_after_edit = state_snapshot(obj) pending_after_edit = bool(document.HasPendingTransaction) active_after_edit = active_transaction_snapshot() document.abortTransaction() transaction_after_abort = vector_value(obj, property_name) state_after_abort = state_snapshot(obj) recovery_recompute_result = bool(document.recompute()) transaction_after_recompute = vector_value(obj, property_name) state_after_recompute = state_snapshot(obj) transaction_objects_after = object_set(document) return { "object": {"name": obj.Name, "typeId": obj.TypeId}, "property": {"name": property_name, "typeId": obj.getTypeIdOfProperty(property_name)}, "disabled": disabled, "transaction": { "before": transaction_before, "edited": transaction_edited, "afterAbort": transaction_after_abort, "afterRecompute": transaction_after_recompute, "stateAfterEdit": state_after_edit, "stateAfterAbort": state_after_abort, "stateAfterRecompute": state_after_recompute, "objectsBefore": transaction_objects_before, "objectsAfter": transaction_objects_after, "undoMode": int(document.UndoMode), "pendingAfterEdit": pending_after_edit, "pendingAfterAbort": bool(document.HasPendingTransaction), "activeAfterEdit": active_after_edit, "activeAfterAbort": active_transaction_snapshot(), "recoveryRecomputeResult": recovery_recompute_result, "restored": transaction_before == transaction_after_abort == transaction_after_recompute, "objectsRestored": transaction_objects_before == transaction_objects_after, }, "documentIntegrity": { "initialObjects": initial_objects, "finalObjects": object_set(document), "objectsPreserved": initial_objects == object_set(document), }, } finally: App.closeDocument(document.Name) NON_FINITE_VALUES = { "nan": (float("nan"), 0.0, 1.0), "positive-infinity": (float("inf"), 0.0, 1.0), "negative-infinity": (float("-inf"), 0.0, 1.0), } if PROBE_MODE.startswith("non-finite:"): _, host_id, value_id = PROBE_MODE.split(":", 2) host_options = { "Mirroring": ("Part::Mirroring", setup_mirroring), "ProjectOnSurface": ("Part::ProjectOnSurface", setup_projection), } object_type_id, setup = host_options[host_id] print("FREECAD_PROPERTY_DIRECTION_PROGRESS=" + host_id + "-" + value_id, flush=True) isolated_case = run_consumer_direction(host_id + "-" + value_id, object_type_id, setup, NON_FINITE_VALUES[value_id]) isolated_report = { "schemaVersion": 1, "status": "pass", "baselineId": "freecad-1.1.1-property-direction-failure-isolated", "freecadVersion": ".".join(str(value) for value in App.Version()[:3]), "gitCommit": FREECAD_COMMIT, "propertyType": "App::PropertyDirection", "case": isolated_case, } if not OUTPUT_PATH: raise RuntimeError("FREECAD_PROPERTY_DIRECTION_FAILURE_OUTPUT is required") with open(OUTPUT_PATH, "w", encoding="utf-8") as handle: json.dump(isolated_report, handle, indent=2, sort_keys=True, allow_nan=False) handle.write("\n") print("FREECAD_PROPERTY_DIRECTION_FAILURE_ISOLATED_RESULT=" + json.dumps({"status": "pass", "caseId": isolated_case["id"]}, sort_keys=True)) raise SystemExit(0) setter_boundaries = run_setter_boundaries() consumer_failures = [] for host_id, object_type_id, setup in [ ("Mirroring", "Part::Mirroring", setup_mirroring), ("ProjectOnSurface", "Part::ProjectOnSurface", setup_projection), ]: print("FREECAD_PROPERTY_DIRECTION_PROGRESS=" + host_id + "-zero", flush=True) consumer_failures.append(run_consumer_direction(host_id + "-zero", object_type_id, setup, (0.0, 0.0, 0.0))) def remove_mirror_source(obj): obj.Source = None return {"property": "Source", "value": None} def remove_projection_support(obj): obj.SupportFace = None return {"property": "SupportFace", "value": None} def remove_projection_inputs(obj): obj.Projection = [] return {"property": "Projection", "value": []} dependency_failures = [ run_missing_dependency("Mirroring-no-source", "Part::Mirroring", setup_mirroring, remove_mirror_source), run_missing_dependency("ProjectOnSurface-no-support", "Part::ProjectOnSurface", setup_projection, remove_projection_support), run_missing_dependency("ProjectOnSurface-no-projection", "Part::ProjectOnSurface", setup_projection, remove_projection_inputs), ] disabled_and_transaction = run_disabled_and_transaction() report = { "schemaVersion": 1, "status": "pass", "baselineId": "freecad-1.1.1-property-direction-failure", "freecadVersion": ".".join(str(value) for value in App.Version()[:3]), "gitCommit": FREECAD_COMMIT, "propertyType": "App::PropertyDirection", "setterBoundaries": setter_boundaries, "consumerFailures": consumer_failures, "dependencyFailures": dependency_failures, "disabled": disabled_and_transaction["disabled"], "transaction": disabled_and_transaction["transaction"], "documentIntegrity": { "setter": setter_boundaries["documentIntegrity"], "disabledAndTransaction": disabled_and_transaction["documentIntegrity"], "allObjectsPreserved": setter_boundaries["documentIntegrity"]["objectsPreserved"] and disabled_and_transaction["documentIntegrity"]["objectsPreserved"] and all(entry["objectsPreserved"] for entry in consumer_failures) and all(entry["objectsPreserved"] for entry in dependency_failures), }, "cancellationBoundary": { "supported": False, "classification": "synchronous-property-setter", "reason": "no-native-cancel-hook", "replacement": "abort-active-document-transaction", }, } if not OUTPUT_PATH: raise RuntimeError("FREECAD_PROPERTY_DIRECTION_FAILURE_OUTPUT is required") with open(OUTPUT_PATH, "w", encoding="utf-8") as handle: json.dump(report, handle, indent=2, sort_keys=True, allow_nan=False) handle.write("\n") print("FREECAD_PROPERTY_DIRECTION_FAILURE_RESULT=" + json.dumps({ "status": report["status"], "setterFailureCount": len(setter_boundaries["failures"]), "consumerFailureCount": len(consumer_failures), "dependencyFailureCount": len(dependency_failures), }, sort_keys=True))