301 lines
13 KiB
Python
301 lines
13 KiB
Python
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=(",", ":")))
|