import hashlib import json import os import re import tempfile import zipfile import FreeCAD as App import Part import Sketcher FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" BUILDER_STAGE_TYPES = { "Part::Fuse", "Part::Cut", "Part::Common", "Part::Extrusion", "Part::Revolution", "Part::Loft", "Part::Sweep", "Part::Fillet", "Part::Chamfer", "PartDesign::Pad", "PartDesign::Pocket", "PartDesign::Revolution", "PartDesign::Groove", "PartDesign::AdditiveLoft", "PartDesign::SubtractiveLoft", "PartDesign::AdditivePipe", "PartDesign::SubtractivePipe", "PartDesign::Fillet", "PartDesign::Chamfer", "PartDesign::Draft", "PartDesign::Thickness", "PartDesign::Mirrored", "PartDesign::MultiTransform", "PartDesign::LinearPattern", "PartDesign::PolarPattern", "PartDesign::Hole", } def version_text(): return ".".join(str(value) for value in App.Version()[:3]) def square_sketch(body, name, half_size=2.0, z=0.0): sketch = body.newObject("Sketcher::SketchObject", name) points = [(-half_size, -half_size), (half_size, -half_size), (half_size, half_size), (-half_size, half_size)] for index, start in enumerate(points): end = points[(index + 1) % len(points)] sketch.addGeometry(Part.LineSegment(App.Vector(start[0], start[1], 0), App.Vector(end[0], end[1], 0)), False) sketch.Placement.Base.z = z return sketch def shape_summary(shape): if shape is None or shape.isNull(): return {"valid": False, "solids": 0, "faces": 0, "edges": 0, "vertices": 0, "volume": 0.0, "area": 0.0, "brepSha256": None} brep = shape.exportBrepToString() bounds = shape.BoundBox geometry = { "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "vertices": len(shape.Vertexes), "volume": round(float(shape.Volume), 9), "area": round(float(shape.Area), 9), "bounds": [round(float(value), 9) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)], "vertexPoints": sorted([round(float(vertex.Point.x), 9), round(float(vertex.Point.y), 9), round(float(vertex.Point.z), 9)] for vertex in shape.Vertexes), "edgeLengths": sorted(round(float(edge.Length), 9) for edge in shape.Edges), "faceAreas": sorted(round(float(face.Area), 9) for face in shape.Faces), } return { "valid": bool(shape.isValid()), "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "vertices": len(shape.Vertexes), "volume": float(shape.Volume), "area": float(shape.Area), "brepSha256": hashlib.sha256(brep.encode("utf-8")).hexdigest(), "geometryDigest": digest_json(geometry), } def history_entry(obj, name): try: history = obj.getElementHistory(name) except Exception: return [] result = [] for source, mapped, children in history: result.append({"object": getattr(source, "Name", ""), "typeId": getattr(source, "TypeId", ""), "mappedName": mapped, "children": list(children or [])}) return result def relation_from_mapped_name(mapped_name): if not mapped_name: return "preserved" if ":G" in mapped_name: return "generated" if ":M" in mapped_name: return "modified" return "preserved" def normalize_mapped_name(mapped_name): # StringHasher IDs are process-local. Preserve the token grammar while # removing only the volatile hash-table slot assigned in this process. return re.sub(r":H-?[0-9a-fA-F]+", ":H#", mapped_name or "") def source_element_name(mapped_name): match = re.search(r"(?:^|[;(])((?:Face|Edge|Vertex)\d+)", mapped_name or "") if match: return match.group(1) local_token = re.search(r"(?:^|[;(])(#[a-zA-Z0-9]+:[0-9a-fA-F]+)", mapped_name or "") if local_token: return local_token.group(1) sketch_token = re.search(r"(?:^|[;(])(g\d+(?:v\d+)?;SKT)", mapped_name or "") if sketch_token: return sketch_token.group(1) indexed_token = re.search(r"(?:^|[;(])(#[0-9a-fA-F]+)", mapped_name or "") return indexed_token.group(1) if indexed_token else "" def linked_objects(obj): result = set() def collect(value): if hasattr(value, "Name") and getattr(value, "Document", None) is obj.Document: result.add(value.Name) elif isinstance(value, (list, tuple)): for item in value: collect(item) for property_name in obj.PropertiesList: try: property_type = obj.getTypeIdOfProperty(property_name) except Exception: continue if "PropertyLink" not in property_type: continue try: collect(getattr(obj, property_name)) except Exception: pass result.discard(obj.Name) return sorted(result) def relation_records(obj, names): records = [] for entry in names: history = entry.get("history") or [] sources = [] for item in history: if item.get("object") == obj.Name or not item.get("object"): continue sources.append({ "sourceObject": item.get("object"), "sourceTypeId": item.get("typeId"), "sourceElement": source_element_name(item.get("mappedName")), "mappedName": normalize_mapped_name(item.get("mappedName")), "children": sorted(normalize_mapped_name(child) for child in (item.get("children") or [])), }) records.append({ "resultName": entry["name"], "mappedName": normalize_mapped_name(entry.get("mappedName")), "indexedName": entry.get("indexedName") or "", "relation": relation_from_mapped_name(entry.get("mappedName")), "sources": sorted(sources, key=lambda source: (source["sourceObject"], source["sourceElement"], source["mappedName"])), }) return records def digest_json(value): payload = json.dumps(value, sort_keys=True, separators=(",", ":")) return hashlib.sha256(payload.encode("utf-8")).hexdigest() def native_mapping(obj, kind, index, name, mapped_name, mapped_ids, indexed_name, indexed_ids, history): reference_name = mapped_name or indexed_name or name source_refs = [] for item in history[1:]: source_name = item.get("object") source_mapped_name = item.get("mappedName") or "" if not source_name: continue source_refs.append({ "objectId": source_name, # FreeCAD exposes the source object and mapped name, but not a # persistent source subshape id in this API response. "persistentId": "%s:mapped:%s" % (source_name, source_mapped_name or "%s%d" % (kind, index)), "stageId": source_name, "mappedName": source_mapped_name, }) return { "kind": kind.lower(), "resultIndex": index - 1, "resultPersistentId": "%s:%s:%d" % (obj.Name, kind.lower(), index - 1), "reference": { "name": reference_name, "stringIds": list(mapped_ids or indexed_ids or []), "indexedName": {"type": kind, "index": index}, }, "relation": relation_from_mapped_name(mapped_name), "sourceRefs": source_refs, "nativeApi": "getElementMappedName/getElementIndexedName/getElementHistory", "mappedName": mapped_name, "indexedName": indexed_name, "mappedStringIds": list(mapped_ids or []), "indexedStringIds": list(indexed_ids or []), } def stage_report(obj): shape = getattr(obj, "Shape", None) if shape is None or shape.isNull(): return None names = [] native_mapped_names = [] native_evidence_complete = True mapped_name_entries = 0 indexed_name_entries = 0 history_entry_count = 0 for kind, count in (("Face", len(shape.Faces)), ("Edge", len(shape.Edges)), ("Vertex", len(shape.Vertexes))): for index in range(1, count + 1): name = "%s%d" % (kind, index) try: mapped, mapped_ids = shape.getElementMappedName(name, True) except Exception: mapped, mapped_ids = "", [] try: indexed, indexed_ids = shape.getElementIndexedName(name, True) except Exception: indexed, indexed_ids = "", [] history = history_entry(obj, name) history_entry_count += len(history) if not mapped and not indexed: native_evidence_complete = False if mapped: mapped_name_entries += 1 if indexed: indexed_name_entries += 1 names.append({ "name": name, "mappedName": mapped, "mappedStringIds": list(mapped_ids or []), "indexedName": indexed, "indexedStringIds": list(indexed_ids or []), "history": history, }) native_mapped_names.append(native_mapping(obj, kind, index, name, mapped, mapped_ids, indexed, indexed_ids, history)) relations = relation_records(obj, names) native_evidence = { "schemaVersion": 1, "stageId": obj.Name, "resultObjectId": obj.Name, "status": "native-evidence" if native_evidence_complete and native_mapped_names else "missing", "mappedNames": native_mapped_names, "source": "FreeCAD 1.1.1 runtime API", "mappedNameApiEntries": mapped_name_entries, "indexedNameApiEntries": indexed_name_entries, # FreeCAD exposes MappedName only for derived/builder results. Primitive # and support stages legitimately expose IndexedName without a private # token; record that boundary explicitly instead of treating it as a # failed token capture. "privateTokenEvidenceRequired": mapped_name_entries > 0, "privateTokenEvidenceComplete": mapped_name_entries == len(native_mapped_names) and mapped_name_entries > 0, "indexedNameOnly": mapped_name_entries == 0 and indexed_name_entries == len(native_mapped_names) and indexed_name_entries > 0, "internalBuilderEvidence": obj.TypeId in BUILDER_STAGE_TYPES and history_entry_count > 0 and native_evidence_complete, "reason": None if native_evidence_complete else "One or more subshapes had no mapped or indexed name from the native API.", } return { "name": obj.Name, "label": obj.Label, "typeId": obj.TypeId, "shape": shape_summary(shape), "elementMapVersion": getattr(shape, "ElementMapVersion", None), "elementMapSize": int(getattr(shape, "ElementMapSize", 0) or 0), "names": names, "linkedObjects": linked_objects(obj), "relations": relations, "relationDigest": digest_json(relations), "semanticNameDigest": digest_json([{ "name": entry["name"], "mappedName": normalize_mapped_name(entry.get("mappedName")), "indexedName": entry.get("indexedName") or "", } for entry in names]), "nativeEvidence": native_evidence, } def correlation_snapshot(case_id, stages): return [{ "key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]), "ordinal": ordinal, "name": stage["name"], "typeId": stage["typeId"], "linkedObjects": stage["linkedObjects"], "relationCount": len(stage["relations"]), "relationDigest": stage["relationDigest"], "semanticNameDigest": stage["semanticNameDigest"], } for ordinal, stage in enumerate(stages)] def parameter_value(value): if hasattr(value, "Value"): return float(value.Value) if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"): return [float(value.x), float(value.y), float(value.z)] if isinstance(value, (bool, int, float, str)) or value is None: return value return str(value) def mutation_stage_snapshot(case_id, stages): return [{ "key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]), "ordinal": ordinal, "name": stage["name"], "typeId": stage["typeId"], "shape": stage["shape"], "relationDigest": stage["relationDigest"], "semanticNameDigest": stage["semanticNameDigest"], } for ordinal, stage in enumerate(stages)] def mutation_geometry_fingerprint(stage): return stage["shape"]["geometryDigest"] def mutation_naming_fingerprint(stage): return (stage["relationDigest"], stage["semanticNameDigest"]) def collect_named_stages(document, stage_names): stages = [] for stage_name in stage_names: obj = document.getObject(stage_name) stage = stage_report(obj) if obj is not None else None if stage is None: raise RuntimeError("mutation removed or invalidated stage %s" % stage_name) stages.append(stage) return stages def capture_mutation(case_id, document, construction, before_stages): final = construction["final"] target = construction["mutationTarget"] property_name = construction["mutationProperty"] edited_value = construction["mutationValue"] stage_names = [stage["name"] for stage in before_stages] stage_ordinals = {stage_name: ordinal for ordinal, stage_name in enumerate(stage_names)} if target.Name not in stage_ordinals or final.Name not in stage_ordinals: raise RuntimeError("mutation target or final object has no captured Shape stage") if property_name not in target.PropertiesList: raise RuntimeError("mutation property %s.%s is not registered" % (target.Name, property_name)) editor_modes = list(target.getEditorMode(property_name)) property_type = target.getTypeIdOfProperty(property_name) native_editable = "ReadOnly" not in editor_modes and "Hidden" not in editor_modes original_value = getattr(target, property_name) before_value = parameter_value(original_value) try: setattr(target, property_name, edited_value) document.recompute() edited_value_readback = parameter_value(getattr(target, property_name)) edited_stages = collect_named_stages(document, stage_names) finally: setattr(target, property_name, original_value) document.recompute() restored_value = parameter_value(getattr(target, property_name)) restored_stages = collect_named_stages(document, stage_names) before_geometry = [mutation_geometry_fingerprint(stage) for stage in before_stages] edited_geometry = [mutation_geometry_fingerprint(stage) for stage in edited_stages] restored_geometry = [mutation_geometry_fingerprint(stage) for stage in restored_stages] before_naming = [mutation_naming_fingerprint(stage) for stage in before_stages] edited_naming = [mutation_naming_fingerprint(stage) for stage in edited_stages] restored_naming = [mutation_naming_fingerprint(stage) for stage in restored_stages] changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_geometry, edited_geometry)) if before != edited] restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_geometry, restored_geometry)) if before != restored] naming_changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_naming, edited_naming)) if before != edited] naming_restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_naming, restored_naming)) if before != restored] final_ordinal = stage_ordinals[final.Name] target_ordinal = stage_ordinals[target.Name] property_changed = before_value != edited_value_readback final_shape_changed = before_geometry[final_ordinal] != edited_geometry[final_ordinal] final_brep_changed = before_stages[final_ordinal]["shape"]["brepSha256"] != edited_stages[final_ordinal]["shape"]["brepSha256"] property_restored = before_value == restored_value all_stages_restored = len(restoration_drift_ordinals) == 0 edited_shapes_valid = all(stage["shape"]["valid"] for stage in edited_stages) status = "pass" if native_editable and property_changed and final_shape_changed and property_restored and all_stages_restored and edited_shapes_valid else "failed" return { "schemaVersion": 1, "category": construction["category"], "contract": { "targetObject": target.Name, "targetTypeId": target.TypeId, "targetStageOrdinal": target_ordinal, "propertyPath": property_name, "propertyType": property_type, "editorModes": editor_modes, "nativeEditable": native_editable and property_type.startswith("App::Property"), "finalObject": final.Name, "finalStageOrdinal": final_ordinal, "requiresFinalPropagation": True, "requiresAllStageRestore": True, }, "values": { "before": before_value, "edited": edited_value_readback, "restored": restored_value, }, "phases": { "before": mutation_stage_snapshot(case_id, before_stages), "edited": mutation_stage_snapshot(case_id, edited_stages), "restored": mutation_stage_snapshot(case_id, restored_stages), }, "metrics": { "propertyChanged": property_changed, "finalShapeChanged": final_shape_changed, "finalBrepChanged": final_brep_changed, "propertyRestored": property_restored, "allStagesRestored": all_stages_restored, "allStageGeometryRestored": all_stages_restored, "allStageNamingRestored": len(naming_restoration_drift_ordinals) == 0, "editedShapesValid": edited_shapes_valid, "changedStageOrdinals": changed_ordinals, "changedStages": len(changed_ordinals), "restorationDriftOrdinals": restoration_drift_ordinals, "restorationDriftStages": len(restoration_drift_ordinals), "namingChangedStageOrdinals": naming_changed_ordinals, "namingChangedStages": len(naming_changed_ordinals), "namingRestorationDriftOrdinals": naming_restoration_drift_ordinals, "namingRestorationDriftStages": len(naming_restoration_drift_ordinals), "stageRecords": len(before_stages), "phaseStageRecords": len(before_stages) * 3, }, "status": status, } def make_boolean(doc, operation, index): base = doc.addObject("Part::Box", "Box%02d" % index) base.Length, base.Width, base.Height = 10.0, 10.0, 10.0 tool = doc.addObject("Part::Cylinder", "Cylinder%02d" % index) tool.Radius, tool.Height = 3.0 + (index % 3), 14.0 tool.Placement.Base = App.Vector(5.0, 5.0, -2.0) if operation == "fuse": result = doc.addObject("Part::Fuse", "Fuse%02d" % index) elif operation == "common": result = doc.addObject("Part::Common", "Common%02d" % index) else: result = doc.addObject("Part::Cut", "Cut%02d" % index) result.Base, result.Tool = base, tool return { "category": "boolean", "final": result, "base": base, "mutationTarget": tool, "mutationProperty": "Radius", "mutationValue": float(tool.Radius.Value) + 0.5, } def make_partdesign(doc, mode, index): body = doc.addObject("PartDesign::Body", "Body%02d" % index) sketch = square_sketch(body, "Sketch%02d" % index, 2.0 + (index % 2) * 0.5) pad = body.newObject("PartDesign::Pad", "Pad%02d" % index) pad.Profile, pad.Length = sketch, 5.0 + (index % 4) if mode == "midplane": pad.Midplane = True elif mode == "reverse": pad.Reversed = True elif mode == "taper": pad.TaperAngle = 4.0 elif mode == "twoside": pad.Length2 = 2.0 if hasattr(pad, "Type2"): pad.Type2 = "Length" doc.recompute() if mode.startswith("pocket"): base = pad pocket_sketch = square_sketch(body, "PocketSketch%02d" % index, 0.8, 5.0) pocket = body.newObject("PartDesign::Pocket", "Pocket%02d" % index) pocket.Profile = pocket_sketch if hasattr(pocket, "BaseFeature"): pocket.BaseFeature = base pocket.Length = 3.0 if mode == "pocket-through": pocket.Type = 1 elif mode == "pocket-midplane": pocket.Midplane = True elif mode == "pocket-twoside": pocket.Length2 = 1.0 doc.recompute() return { "category": "partdesign", "final": pocket, "mutationTarget": pad, "mutationProperty": "Length", "mutationValue": float(pad.Length.Value) + 0.75, } return { "category": "partdesign", "final": pad, "mutationTarget": pad, "mutationProperty": "Length", "mutationValue": float(pad.Length.Value) + 0.75, } def make_composite(doc, index): first_construction = make_boolean(doc, "fuse" if index % 2 else "cut", index) first = first_construction["final"] second = doc.addObject("Part::Box", "SecondBox%02d" % index) second.Length, second.Width, second.Height = 4.0, 4.0, 4.0 second.Placement.Base = App.Vector(3.0, 3.0, 3.0) final = doc.addObject("Part::Cut", "CompositeCut%02d" % index) final.Base, final.Tool = first, second return { "category": "composite-boolean", "final": final, "mutationTarget": first_construction["base"], "mutationProperty": "Length", "mutationValue": float(first_construction["base"].Length.Value) + 0.5, } def make_dressup(doc, mode, index): body = doc.addObject("PartDesign::Body", "DressupBody%02d" % index) box = body.newObject("PartDesign::AdditiveBox", "DressupBox%02d" % index) box.Length, box.Width, box.Height = 10.0, 10.0, 10.0 doc.recompute() if mode == "fillet": feature = body.newObject("PartDesign::Fillet", "Fillet%02d" % index) feature.Base, feature.Radius = (box, ["Edge1"]), 1.0 else: feature = body.newObject("PartDesign::Chamfer", "Chamfer%02d" % index) feature.Base, feature.Size = (box, ["Edge1"]), 1.0 doc.recompute() return { "category": "dress-up", "final": feature, "mutationTarget": feature, "mutationProperty": "Radius" if mode == "fillet" else "Size", "mutationValue": 1.25, } def collect_case(case_id, factory, index, directory): name = "ElementMapOracle%02d" % index doc = App.newDocument(name) try: construction = factory(doc, index) final = construction["final"] doc.recompute() stages = [entry for obj in doc.Objects if (entry := stage_report(obj)) is not None] mutation = capture_mutation(case_id, doc, construction, stages) stages = collect_named_stages(doc, [stage["name"] for stage in stages]) before_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in stages for entry in stage["names"]} initial_correlations = correlation_snapshot(case_id, stages) final_name = getattr(final, "Name", "") path = os.path.join(directory, "%s.FCStd" % case_id) doc.saveAs(path) resources = {} string_hasher_resource = None with zipfile.ZipFile(path, "r") as archive: for resource_path in archive.namelist(): if resource_path == "StringHasher.Table.txt": raw = archive.read(resource_path) try: text = raw.decode("utf-8") except UnicodeDecodeError: text = None string_hasher_resource = {"sha256": hashlib.sha256(raw).hexdigest(), "text": text} if text is not None else {"sha256": hashlib.sha256(raw).hexdigest(), "text": None} continue if not resource_path.endswith(".Map.txt"): continue raw = archive.read(resource_path) try: text = raw.decode("utf-8") except UnicodeDecodeError: continue resources[resource_path] = {"sha256": hashlib.sha256(raw).hexdigest(), "text": text} stages = [dict(stage, namingEvidenceStatus=stage["nativeEvidence"]["status"], elementMap2ResourcePaths=[path for path in resources if path.startswith(stage["name"] + ".")]) for stage in stages] App.closeDocument(doc.Name) reopened = App.openDocument(path) reopened.recompute() after_stages = [entry for obj in reopened.Objects if (entry := stage_report(obj)) is not None] after_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in after_stages for entry in stage["names"]} reopened_correlations = correlation_snapshot(case_id, after_stages) roundtrip_drift = sum(1 for key, value in before_names.items() if after_names.get(key) != value) resaved_path = os.path.join(directory, "%s-resaved.FCStd" % case_id) reopened.saveAs(resaved_path) App.closeDocument(reopened.Name) resaved = App.openDocument(resaved_path) resaved.recompute() resaved_stages = [entry for obj in resaved.Objects if (entry := stage_report(obj)) is not None] resaved_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in resaved_stages for entry in stage["names"]} resaved_correlations = correlation_snapshot(case_id, resaved_stages) resave_name_drift = sum(1 for key, value in before_names.items() if resaved_names.get(key) != value) App.closeDocument(resaved.Name) return {"id": case_id, "category": construction["category"], "finalObject": final_name, "stages": stages, "mutation": mutation, "stageCorrelations": {"initial": initial_correlations, "reopened": reopened_correlations, "resaved": resaved_correlations}, "elementMapResources": resources, "stringHasherResource": string_hasher_resource, "roundtripNameDrift": roundtrip_drift, "resaveNameDrift": resave_name_drift, "nativeDesktopResaveCovered": True, "status": "pass" if mutation["status"] == "pass" else "failed"} except Exception as error: return {"id": case_id, "finalObject": "", "stages": [], "elementMapResources": {}, "status": "failed", "error": str(error)} finally: if name in App.listDocuments(): App.closeDocument(name) factories = [] for operation in ("fuse", "cut", "common"): for variant in range(1, 4): factories.append(("boolean-%s-%d" % (operation, variant), lambda doc, index, op=operation: make_boolean(doc, op, index))) for mode in ("plain", "midplane", "reverse", "taper", "twoside", "pocket", "pocket-through", "pocket-midplane", "pocket-twoside", "pocket-taper", "pocket-up-to-face"): factories.append(("partdesign-%s" % mode, lambda doc, index, value=mode: make_partdesign(doc, value, index))) for variant in range(1, 7): factories.append(("composite-%02d" % variant, make_composite)) for mode in ("fillet", "chamfer"): for variant in range(1, 3): factories.append(("dressup-%s-%d" % (mode, variant), lambda doc, index, value=mode: make_dressup(doc, value, index))) with tempfile.TemporaryDirectory(prefix="freecad-elementmap-oracle-") as directory: cases = [collect_case(case_id, factory, index + 1, directory) for index, (case_id, factory) in enumerate(factories[:30])] version = App.Version() report = { "schemaVersion": 1, "mutationContractVersion": 1, "baselineId": "freecad-1.1.1-composite-history-elementmap2", "freecadVersion": version_text(), "gitCommit": FREECAD_COMMIT, "status": "pass" if len(cases) == 30 and all(case["status"] == "pass" for case in cases) else "failed", "cases": cases, "summary": { "cases": len(cases), "passed": sum(1 for case in cases if case["status"] == "pass"), "failed": sum(1 for case in cases if case["status"] != "pass"), "categoryCases": {category: sum(1 for case in cases if case.get("category") == category) for category in ("boolean", "partdesign", "composite-boolean", "dress-up")}, "stageCorrelations": sum(len(case.get("stages", [])) for case in cases), "relationRecords": sum(sum(len(stage.get("relations", [])) for stage in case.get("stages", [])) for case in cases), "mutationCases": sum(1 for case in cases if case.get("mutation")), "mutationPassed": sum(1 for case in cases if case.get("mutation", {}).get("status") == "pass"), "mutationStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("stageRecords", 0) for case in cases), "mutationPhaseStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("phaseStageRecords", 0) for case in cases), "mutationFinalPropagationFailures": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("finalShapeChanged") is not True), "mutationStageRestoreFailures": sum(case.get("mutation", {}).get("metrics", {}).get("restorationDriftStages", 0) for case in cases), "mutationNamingRestoreDriftCases": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) > 0), "mutationNamingRestoreDriftStages": sum(case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) for case in cases), "roundtripNameDrift": sum(case.get("roundtripNameDrift", 0) for case in cases), "resaveNameDrift": sum(case.get("resaveNameDrift", 0) for case in cases), "nativeDesktopResaveCases": sum(1 for case in cases if case.get("nativeDesktopResaveCovered") is True), }, } print("FREECAD_COMPOSITE_HISTORY_ELEMENTMAP_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))