import hashlib import json import os import tempfile import zipfile import FreeCAD as App import Part import Sketcher FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d" 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, "volume": 0.0} return {"valid": bool(shape.isValid()), "solids": len(shape.Solids), "faces": len(shape.Faces), "edges": len(shape.Edges), "volume": float(shape.Volume)} 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 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 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) 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)) 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, "privateTokenEvidenceComplete": mapped_name_entries == len(native_mapped_names) and mapped_name_entries > 0, "internalBuilderEvidence": False, "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, "nativeEvidence": native_evidence, } 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 result 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 pocket return pad def make_composite(doc, index): first = make_boolean(doc, "fuse" if index % 2 else "cut", index) 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 final 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 feature def collect_case(case_id, factory, index, directory): name = "ElementMapOracle%02d" % index doc = App.newDocument(name) try: final = factory(doc, index) doc.recompute() stages = [entry for obj in doc.Objects if (entry := stage_report(obj)) is not None] before_names = {(stage["name"], entry["name"]): entry.get("mappedName") for stage in stages for entry in stage["names"]} 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"]): entry.get("mappedName") for stage in after_stages for entry in stage["names"]} roundtrip_drift = sum(1 for key, value in before_names.items() if after_names.get(key) != value) App.closeDocument(reopened.Name) return {"id": case_id, "finalObject": final_name, "stages": stages, "elementMapResources": resources, "stringHasherResource": string_hasher_resource, "roundtripNameDrift": roundtrip_drift, "status": "pass"} 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, "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")}, } print("FREECAD_COMPOSITE_HISTORY_ELEMENTMAP_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))