import hashlib import json import math import os import FreeCAD as App import Import import Part FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" def version_text(): return ".".join(str(value) for value in App.Version()[:3]) def shape_snapshot_from_shape(shape, include_brep=False): if shape.isNull(): raise RuntimeError("ordered pair produced a null Shape") bounds = shape.BoundBox snapshot = { "valid": bool(shape.isValid()), "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "vertices": len(shape.Vertexes), "volume": round(float(shape.Volume), 7), "area": round(float(shape.Area), 7), "bounds": [round(float(value), 7) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)], } if include_brep: brep = shape.exportBrepToString() brep_bytes = brep.encode("utf-8") if isinstance(brep, str) else bytes(brep) snapshot["brepSha256"] = hashlib.sha256(brep_bytes).hexdigest() return snapshot def shape_snapshot(feature): return shape_snapshot_from_shape(feature.Shape) def imported_solid_shapes(document): shapes = [] for obj in document.Objects: if obj.TypeId != "Part::Feature" or not hasattr(obj, "Shape") or obj.Shape.isNull() or not obj.Shape.isValid() or not obj.Shape.Solids: continue bounds = obj.Shape.BoundBox if not all(math.isfinite(float(value)) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)): continue shapes.append(obj.Shape) return shapes def phase_snapshot(document): feature = document.getObject("PairResult") if feature is None: raise RuntimeError("PairResult is absent after FCStd persistence") evidence = str(feature.NamingEvidence) return { "pair": str(feature.OrderedPair), "namingEvidenceBytes": len(evidence.encode("utf-8")), "namingEvidenceSha256": hashlib.sha256(evidence.encode("utf-8")).hexdigest(), "shape": shape_snapshot(feature), } def document_snapshot(document): datum_types = {"App::Line", "App::Plane", "App::Point"} return { "objects": [{ "name": obj.Name, "typeId": obj.TypeId, **({"shape": shape_snapshot(obj)} if obj.TypeId not in datum_types and hasattr(obj, "Shape") and not obj.Shape.isNull() else {}), } for obj in document.Objects], } def object_set_snapshot(document): return [{"name": obj.Name, "typeId": obj.TypeId} for obj in document.Objects] def link_snapshot(value): linked = value subelements = [] if isinstance(value, tuple): linked = value[0] if len(value) > 1: subelements = [str(item) for item in value[1]] return { "name": linked.Name if linked is not None else None, "typeId": linked.TypeId if linked is not None else None, "subelements": subelements, } def collect_accepted(): step_path = os.environ["FREECAD_PAIR_STEP_PATH"] evidence_path = os.environ["FREECAD_PAIR_EVIDENCE_PATH"] pair = os.environ["FREECAD_ORDERED_PAIR"] output_directory = os.environ["FREECAD_PAIR_OUTPUT_DIRECTORY"] with open(evidence_path, "r", encoding="utf-8") as evidence_file: evidence = evidence_file.read() document = App.newDocument("FreeCadOrderedPairResave") try: Import.insert(step_path, document.Name) document.recompute() source_shapes = imported_solid_shapes(document) if not source_shapes: raise RuntimeError("STEP import produced no Shape") result = document.addObject("Part::Feature", "PairResult") result.Shape = source_shapes[0].copy() if len(source_shapes) == 1 else Part.makeCompound([shape.copy() for shape in source_shapes]) result.addProperty("App::PropertyString", "OrderedPair", "Parity") result.addProperty("App::PropertyString", "NamingEvidence", "Parity") result.OrderedPair = pair result.NamingEvidence = evidence document.recompute() initial = phase_snapshot(document) initial_path = os.path.join(output_directory, "ordered-pair.FCStd") resaved_path = os.path.join(output_directory, "ordered-pair-resaved.FCStd") document.saveAs(initial_path) App.closeDocument(document.Name) reopened = App.openDocument(initial_path) reopened.recompute() reopened_snapshot = phase_snapshot(reopened) reopened.saveAs(resaved_path) App.closeDocument(reopened.Name) resaved = App.openDocument(resaved_path) resaved.recompute() resaved_snapshot = phase_snapshot(resaved) App.closeDocument(resaved.Name) return { "schemaVersion": 1, "baselineId": "freecad-1.1.1-ordered-operation-pair-resave", "freecadVersion": version_text(), "gitCommit": FREECAD_COMMIT, "pair": pair, "decision": "accepted", "phases": {"initial": initial, "reopened": reopened_snapshot, "resaved": resaved_snapshot}, "checks": { "shapeStable": initial["shape"] == reopened_snapshot["shape"] and initial["shape"] == resaved_snapshot["shape"], "namingEvidenceStable": initial["namingEvidenceSha256"] == reopened_snapshot["namingEvidenceSha256"] and initial["namingEvidenceSha256"] == resaved_snapshot["namingEvidenceSha256"], }, "status": "pass", } finally: for name in list(App.listDocuments().keys()): App.closeDocument(name) def collect_rejected(): step_path = os.environ["FREECAD_PAIR_STEP_PATH"] pair = os.environ["FREECAD_ORDERED_PAIR"] operation = pair.split("->", 1)[1] output_directory = os.environ["FREECAD_PAIR_OUTPUT_DIRECTORY"] document = App.newDocument("FreeCadOrderedPairRejection") try: Import.insert(step_path, document.Name) document.recompute() source_objects = [obj for obj in document.Objects if hasattr(obj, "Shape") and not obj.Shape.isNull()] solid_sources = [obj for obj in source_objects if obj.TypeId == "Part::Feature" and obj.Shape.isValid() and len(obj.Shape.Solids) == 1] if len(solid_sources) != 1: candidates = [{"name": obj.Name, "typeId": obj.TypeId, "shape": shape_snapshot_from_shape(obj.Shape)} for obj in solid_sources] raise RuntimeError("Rejected-pair STEP import must produce exactly one valid single-Solid source: " + json.dumps(candidates, sort_keys=True)) source = solid_sources[0] source_name = source.Name source_type_id = source.TypeId document.UndoMode = 1 baseline = document_snapshot(document) baseline_objects = object_set_snapshot(document) baseline_source_shape_object = source.Shape.copy() baseline_source_shape = shape_snapshot_from_shape(source.Shape, include_brep=True) diagnostic = { "exceptionType": None, "exception": None, "recomputeResult": None, "statusString": None, "state": [], "shapeNull": True, "shapeValid": False, } document.openTransaction("ordered-pair-rejection") try: if operation == "pad": body = document.addObject("PartDesign::Body", "Body") feature = body.newObject("PartDesign::Pad", "PairResult") feature.Profile = source feature.Length = 5.0 diagnostic["parameter"] = {"name": "length", "value": round(float(feature.Length), 7)} diagnostic["input"] = link_snapshot(feature.Profile) elif operation == "loft": section = document.addObject("Part::Feature", "Section") section.Shape = Part.makePolygon([ App.Vector(0.0, 0.0, 15.0), App.Vector(2.0, 0.0, 15.0), App.Vector(2.0, 3.0, 15.0), App.Vector(0.0, 3.0, 15.0), App.Vector(0.0, 0.0, 15.0), ]) feature = document.addObject("Part::Loft", "PairResult") feature.Sections = [source, section] feature.Solid = True feature.Ruled = False diagnostic["parameter"] = {"name": "ruled", "value": bool(feature.Ruled)} diagnostic["input"] = { "sections": [{"name": item.Name, "typeId": item.TypeId} for item in feature.Sections], } elif operation == "pipe": spine = document.addObject("Part::Feature", "Spine") spine.Shape = Part.makePolygon([App.Vector(0.0, 0.0, 0.0), App.Vector(0.0, 0.0, 15.0)]) feature = document.addObject("Part::Sweep", "PairResult") feature.Sections = [source] feature.Spine = spine feature.Solid = True feature.Frenet = False diagnostic["parameter"] = {"name": "spineLength", "value": 15.0} diagnostic["input"] = { "sections": [{"name": item.Name, "typeId": item.TypeId} for item in feature.Sections], "spine": link_snapshot(feature.Spine), } elif operation == "revolution": feature = document.addObject("Part::Revolution", "PairResult") feature.Source = source feature.Axis = App.Vector(0.0, 1.0, 0.0) feature.Base = App.Vector(-1.0, 0.0, 0.0) feature.Angle = 360.0 feature.Solid = True diagnostic["parameter"] = {"name": "angle", "value": round(float(feature.Angle), 7)} diagnostic["input"] = link_snapshot(feature.Source) elif operation == "fuse": tool = document.addObject("Part::Feature", "Tool") tool.Shape = Part.makeBox(5.0, 5.0, 5.0, App.Vector(8.0, 0.0, 0.0)) feature = document.addObject("Part::Fuse", "PairResult") feature.Base = source feature.Tool = tool diagnostic["parameter"] = {"name": "toolOffsetX", "value": 8.0} diagnostic["input"] = {"base": link_snapshot(feature.Base), "tool": link_snapshot(feature.Tool)} elif operation == "fillet": feature = document.addObject("Part::Fillet", "PairResult") feature.Base = source edge_indices = [1] if pair == "thickness->fillet" else range(1, len(source.Shape.Edges) + 1) feature.Edges = [(index, 0.4, 0.4) for index in edge_indices] diagnostic["parameter"] = {"name": "radius", "value": 0.4} diagnostic["input"] = link_snapshot(feature.Base) diagnostic["input"]["selectedEdges"] = [entry[0] for entry in feature.Edges] elif operation == "chamfer": feature = document.addObject("Part::Chamfer", "PairResult") feature.Base = source edge_indices = [1] if pair == "thickness->chamfer" else range(1, len(source.Shape.Edges) + 1) feature.Edges = [(index, 0.4, 0.4) for index in edge_indices] diagnostic["parameter"] = {"name": "distance", "value": 0.4} diagnostic["input"] = link_snapshot(feature.Base) diagnostic["input"]["selectedEdges"] = [entry[0] for entry in feature.Edges] elif operation == "draft": body = document.addObject("PartDesign::Body", "Body") draft_base = body.newObject("PartDesign::Feature", "DraftBase") draft_base.Shape = source.Shape.copy() datum_plane = body.newObject("PartDesign::Plane", "DatumPlane") datum_plane.AttachmentSupport = [(document.XY_Plane, "")] datum_plane.MapMode = "FlatFace" datum_line = body.newObject("PartDesign::Line", "DatumLine") datum_line.AttachmentSupport = [(document.Z_Axis, "")] datum_line.MapMode = "TwoPointLine" feature = body.newObject("PartDesign::Draft", "PairResult") feature.Base = (draft_base, ["Face1"]) feature.NeutralPlane = (datum_plane, [""]) feature.PullDirection = (datum_line, [""]) feature.Angle = 5.0 feature.Reversed = False diagnostic["parameter"] = {"name": "angle", "value": round(float(feature.Angle), 7)} diagnostic["input"] = link_snapshot(feature.Base) diagnostic["input"]["sourceName"] = source.Name draft_base_shape = shape_snapshot_from_shape(draft_base.Shape, include_brep=True) diagnostic["input"]["shapeSummaryTransferredExactly"] = {key: value for key, value in draft_base_shape.items() if key != "brepSha256"} == {key: value for key, value in baseline_source_shape.items() if key != "brepSha256"} diagnostic["input"]["sourceBrepSha256"] = baseline_source_shape["brepSha256"] diagnostic["input"]["draftBaseBrepSha256"] = draft_base_shape["brepSha256"] elif operation == "thickness": feature = document.addObject("Part::Thickness", "PairResult") feature.Faces = (source, ["Face2"]) feature.Value = -0.4 feature.Mode = 0 feature.Join = 0 diagnostic["parameter"] = {"name": "offset", "value": round(float(feature.Value), 7)} diagnostic["input"] = link_snapshot(feature.Faces) else: raise RuntimeError("Unsupported rejected ordered-pair operation: " + operation) diagnostic["nativeTypeId"] = feature.TypeId diagnostic["inputShape"] = shape_snapshot_from_shape(source.Shape) diagnostic["objects"] = object_set_snapshot(document) try: diagnostic["recomputeResult"] = bool(document.recompute()) except Exception as error: diagnostic["exceptionType"] = type(error).__name__ diagnostic["exception"] = str(error) diagnostic["statusString"] = str(feature.getStatusString()) diagnostic["state"] = [str(value) for value in feature.State] diagnostic["shapeNull"] = bool(feature.Shape.isNull()) diagnostic["shapeValid"] = False if feature.Shape.isNull() else bool(feature.Shape.isValid()) if not feature.Shape.isNull(): diagnostic["resultShape"] = shape_snapshot_from_shape(feature.Shape) finally: document.abortTransaction() document.recompute() initial = document_snapshot(document) restored_objects = object_set_snapshot(document) restored_source = document.getObject(source_name) if restored_source is None or not hasattr(restored_source, "Shape") or restored_source.Shape.isNull(): raise RuntimeError("Fuse source Shape is absent after transaction abort") restored_source_shape = shape_snapshot_from_shape(restored_source.Shape, include_brep=True) source_shape_summary_restored = { key: value for key, value in baseline_source_shape.items() if key != "brepSha256" } == { key: value for key, value in restored_source_shape.items() if key != "brepSha256" } source_shape_topologically_equal = bool(restored_source.Shape.isEqual(baseline_source_shape_object)) source_shape_symmetric_difference = { "beforeMinusAfterVolume": round(float(baseline_source_shape_object.cut(restored_source.Shape).Volume), 7), "afterMinusBeforeVolume": round(float(restored_source.Shape.cut(baseline_source_shape_object).Volume), 7), } source_shape_geometrically_restored = source_shape_summary_restored and all( value == 0 for value in source_shape_symmetric_difference.values() ) source_brep_reserialized = baseline_source_shape["brepSha256"] != restored_source_shape["brepSha256"] initial_path = os.path.join(output_directory, "ordered-pair-rejection.FCStd") resaved_path = os.path.join(output_directory, "ordered-pair-rejection-resaved.FCStd") document.saveAs(initial_path) App.closeDocument(document.Name) reopened = App.openDocument(initial_path) reopened.recompute() reopened_snapshot = document_snapshot(reopened) reopened.saveAs(resaved_path) App.closeDocument(reopened.Name) resaved = App.openDocument(resaved_path) resaved.recompute() resaved_snapshot = document_snapshot(resaved) App.closeDocument(resaved.Name) feature_rejected = diagnostic["shapeNull"] and ( diagnostic["exceptionType"] is not None or diagnostic["recomputeResult"] is False or diagnostic["statusString"] != "Valid" or "Error" in diagnostic["state"] or "Invalid" in diagnostic["state"] ) freecad_profile_no_op = operation == "thickness" and not feature_rejected and not diagnostic["shapeNull"] and diagnostic["shapeValid"] and diagnostic["statusString"] == "Valid" and diagnostic.get("resultShape") == diagnostic.get("inputShape") freecad_profile_accepted = operation in ("fillet", "chamfer", "thickness") and not freecad_profile_no_op and not feature_rejected and not diagnostic["shapeNull"] and diagnostic["shapeValid"] and diagnostic["statusString"] == "Valid" expected_type_id = {"pad": "PartDesign::Pad", "loft": "Part::Loft", "pipe": "Part::Sweep", "revolution": "Part::Revolution", "fuse": "Part::Fuse", "fillet": "Part::Fillet", "chamfer": "Part::Chamfer", "draft": "PartDesign::Draft", "thickness": "Part::Thickness"}[operation] input_names = [diagnostic.get("input", {}).get("sourceName", diagnostic.get("input", {}).get("name"))] if operation in ("pad", "revolution", "fillet", "chamfer", "draft", "thickness") else ([diagnostic.get("input", {}).get(key, {}).get("name") for key in ("base", "tool")] if operation == "fuse" else [item["name"] for item in diagnostic.get("input", {}).get("sections", [])]) checks = { "nativeFeatureConstructed": diagnostic.get("nativeTypeId") == expected_type_id, "fuseSolidUsedAsInput": source_name in input_names and diagnostic.get("inputShape", {}).get("solids") == 1 and (operation != "draft" or diagnostic.get("input", {}).get("shapeSummaryTransferredExactly") is True), "recomputeStatusCaptured": diagnostic["recomputeResult"] is not None or diagnostic["exceptionType"] is not None, "nativeFeatureRejected": feature_rejected, "freecadProfileAccepted": freecad_profile_accepted, "objectSetRestored": baseline_objects == restored_objects, "sourceShapeRestored": baseline_source_shape == restored_source_shape, "sourceShapeGeometricallyRestored": source_shape_geometrically_restored, "sourceShapeSummaryRestored": source_shape_summary_restored, "documentUnpolluted": baseline == initial, "documentStable": initial == reopened_snapshot and initial == resaved_snapshot, } if freecad_profile_no_op: checks["freecadProfileNoOp"] = True return { "schemaVersion": 1, "baselineId": "freecad-1.1.1-ordered-operation-pair-resave", "freecadVersion": version_text(), "gitCommit": FREECAD_COMMIT, "pair": pair, "decision": "rejected", "operation": operation, "nativeTypeId": expected_type_id, "source": { "name": source_name, "typeId": source_type_id, "shape": baseline_source_shape, }, "diagnostic": diagnostic, "rollback": { "before": {"objects": baseline_objects, "sourceShape": baseline_source_shape}, "after": {"objects": restored_objects, "sourceShape": restored_source_shape}, "sourceBrepReserialized": source_brep_reserialized, "sourceShapeSymmetricDifference": source_shape_symmetric_difference, "sourceShapeTopologicallyEqual": source_shape_topologically_equal, }, "phases": {"initial": initial, "reopened": reopened_snapshot, "resaved": resaved_snapshot}, "checks": checks, "status": "pass" if all(checks.values()) or (operation in ("fillet", "chamfer", "thickness") and freecad_profile_accepted and all(value for key, value in checks.items() if key != "nativeFeatureRejected")) or (freecad_profile_no_op and source_shape_geometrically_restored and all(value for key, value in checks.items() if key not in ("nativeFeatureRejected", "freecadProfileAccepted", "sourceShapeRestored"))) or (not freecad_profile_accepted and feature_rejected and all(value for key, value in checks.items() if key != "freecadProfileAccepted")) else "failed", } finally: for name in list(App.listDocuments().keys()): App.closeDocument(name) try: report = collect_rejected() if os.environ.get("FREECAD_PAIR_DECISION") == "rejected" else collect_accepted() except Exception as error: report = { "schemaVersion": 1, "baselineId": "freecad-1.1.1-ordered-operation-pair-resave", "freecadVersion": version_text(), "gitCommit": FREECAD_COMMIT, "status": "failed", "errorType": type(error).__name__, "error": str(error), } print("FREECAD_ORDERED_OPERATION_PAIR_RESAVE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))