"""Real native nested assembly BOM, mass, occurrence and persistence tests. Missing/wrong targets remain live GUI tests. Deliberate A<->B native link cycles run in an owned FreeCADCmd child when this suite runs inside the GUI: Coin ViewProviders can traverse such a graph during the setter, before a BOM service is called. Both processes' outcomes and source hashes are checked. """ import csv import hashlib import io import json import math import os from pathlib import Path import subprocess import sys import tempfile import traceback import zipfile import FreeCAD as App import Part SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if str(SOURCE) not in sys.path: sys.path.insert(0, str(SOURCE)) import qi_model as model import qi_bom as bom def _near(actual, expected): assert math.isclose(float(getattr(actual, "Value", actual)), float(expected), rel_tol=1e-9, abs_tol=1e-10), (actual, expected) def _state(doc): contents = [] for obj in doc.Objects: with zipfile.ZipFile(io.BytesIO(bytes(obj.dumpContent()))) as archive: contents.append((obj.Name, tuple((name, archive.read(name)) for name in sorted(archive.namelist())), tuple(obj.State))) return contents, doc.UndoCount, doc.RedoCount, App.getActiveTransaction() def _reject(action): try: action() except ValueError as exc: assert any("а" <= c.lower() <= "я" for c in str(exc)), str(exc) return str(exc) raise AssertionError("Invalid CAD BOM/mass was accepted") def _assembly(doc, name): obj = doc.addObject("Assembly::AssemblyObject", name) obj.Type = "Assembly" obj.newObject("Assembly::JointGroup", name + "Joints") return obj def _link(parent, source, name, x=0, y=0, z=0): obj = parent.Document.addObject("App::Link", name) obj.setLink(source) obj.LinkPlacement = App.Placement(App.Vector(x, y, z), App.Rotation()) parent.addObject(obj) return obj def nested_fixture(prefix="BOMNested", rotated=True, flexible=True): doc = model.new_part(prefix) root = _assembly(doc, "Assembly") library = doc.addObject("App::Part", "Definitions") library.Label = "Определения деталей" bolt = model.create_box(2, 3, 4, doc=doc) bolt.Label = "Болт"; bolt.QIPartNumber = "BOLT" housing = model.create_box(10, 10, 10, doc=doc) housing.Label = "Корпус"; housing.QIPartNumber = "HOUSING" model.set_material(housing, "Алюминий") sub = _assembly(doc, "SubAssembly") sub.Label = "Узел" _link(sub, bolt, "BoltA", 0) _link(sub, bolt, "BoltB", 10) _link(sub, housing, "HousingOccurrence", 20) for obj in (bolt, housing, sub): library.addObject(obj) if App.GuiUp: obj.Visibility = False a = doc.addObject("Assembly::AssemblyLink", "SubA") a.LinkedObject = sub root.addObject(a) a.Placement = App.Placement(App.Vector(100, 0, 0), App.Rotation()) b = doc.addObject("Assembly::AssemblyLink", "SubB") b.LinkedObject = sub root.addObject(b) b.Placement = App.Placement(App.Vector(200, 0, 0), App.Rotation()) doc.recompute() if flexible: b.Rigid = False doc.recompute() direct = _link(root, bolt, "DirectBolt", 300) if rotated: root.Placement = App.Placement(App.Vector(1000, 50, 0), App.Rotation(App.Vector(0, 0, 1), 90)) doc.recompute() return doc, root, bolt, housing, sub, a, b, direct def _native_cycle_checks(): """Exercise real invalid native links without a Coin/GUI scene graph.""" assert not App.GuiUp, "Deliberate native link cycles require a headless CAD process" initial_docs = set(App.listDocuments()) rejections = [] try: cycle_doc = model.new_part("BOMLinkCycle") a = cycle_doc.addObject("App::Link", "A") b = cycle_doc.addObject("App::Link", "B") a.LinkedObject = b; b.LinkedObject = a try: assert a.LinkedObject is b and b.LinkedObject is a rejections.append(_reject(lambda: bom.bom_rows(cycle_doc))) rejections.append(_reject(lambda: bom.bom_summary(cycle_doc))) rejections.append(_reject(lambda: bom.mass_properties(a))) finally: a.LinkedObject = None; b.LinkedObject = None assembly_doc = model.new_part("BOMAssemblyCycle") assembly_a = _assembly(assembly_doc, "AssemblyA") assembly_b = _assembly(assembly_doc, "AssemblyB") link_a = assembly_doc.addObject("Assembly::AssemblyLink", "ToB") link_a.LinkedObject = assembly_b; assembly_a.addObject(link_a) link_b = assembly_doc.addObject("Assembly::AssemblyLink", "ToA") link_b.LinkedObject = assembly_a; assembly_b.addObject(link_b) try: assert link_a.LinkedObject is assembly_b and link_b.LinkedObject is assembly_a rejections.append(_reject(lambda: bom.bom_rows(assembly_doc))) finally: link_a.LinkedObject = None; link_b.LinkedObject = None finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) return {"real_two_link_cycle_guarded_before_delegated_geometry": True, "real_cross_assembly_cycle_guarded": True, "rejection_count": len(rejections), "rejections": rejections, "gui_up": bool(App.GuiUp)} def run_cycles_only(output_dir): """Headless endpoint used by the GUI suite's owned FreeCADCmd child.""" output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) report = {"passed": False, "service_path": str(Path(bom.__file__).resolve()), "service_sha256": hashlib.sha256(Path(bom.__file__).read_bytes()).hexdigest(), "suite_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "freecad_version": App.Version(), "gui_up": bool(App.GuiUp), "user_cfg": App.ConfigGet("UserParameter"), "system_cfg": App.ConfigGet("SystemParameter")} try: report["evidence"] = _native_cycle_checks() report["passed"] = True except Exception: report["error"] = traceback.format_exc() (output / "cycle-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report def _owned_cycle_child(output): # A raw A<->B link cycle is accepted by the native app model, but a GUI # ViewProvider may traverse it immediately in SoFCUnifiedSelection before # our service gets control. Keep the same actual cycle assertions in a real # headless FreeCADCmd process; do not create the invalid Coin graph here. directory = Path(tempfile.mkdtemp(prefix="bom-cycle-", dir=str(output))) suite_path = Path(__file__).resolve() macro = directory / "cycles.FCMacro" macro.write_text("import sys\n" + "sys.path.insert(0, " + repr(str(suite_path.parent)) + ")\n" + "import bom_suite\nbom_suite.run_cycles_only(" + repr(str(directory)) + ")\n", encoding="utf-8") engine = Path(App.getHomePath()) / "bin" / "FreeCADCmd.exe" assert engine.is_file(), str(engine) user_cfg = directory / "user.cfg" system_cfg = directory / "system.cfg" args = [str(engine), "--user-cfg", str(user_cfg), "--system-cfg", str(system_cfg), "--log-file", str(directory / "native.log"), str(macro)] environment = os.environ.copy() environment.pop("QI_TEST", None) environment["APPDATA"] = str(directory / "appdata") environment["LOCALAPPDATA"] = str(directory / "localappdata") for name in ("APPDATA", "LOCALAPPDATA"): Path(environment[name]).mkdir() options = {} if os.name == "nt": options["creationflags"] = subprocess.CREATE_NO_WINDOW startup = subprocess.STARTUPINFO() startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW startup.wShowWindow = subprocess.SW_HIDE options["startupinfo"] = startup with (directory / "process.log").open("wb") as stream: process = subprocess.Popen(args, cwd=str(directory), stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, env=environment, **options) try: exit_code = process.wait(timeout=90) except subprocess.TimeoutExpired: process.kill() # only this owned native child, never another CAD PID process.wait(timeout=10) raise AssertionError("Owned headless native cycle check timed out: " + str(directory)) report_path = directory / "cycle-report.json" assert exit_code == 0, (exit_code, str(directory / "process.log")) assert report_path.is_file(), str(directory / "process.log") report = json.loads(report_path.read_text(encoding="utf-8")) assert report["passed"] and not report["gui_up"], report assert report["evidence"]["rejection_count"] == 4, report assert report["service_sha256"] == hashlib.sha256(Path(bom.__file__).read_bytes()).hexdigest(), report assert report["suite_sha256"] == hashlib.sha256(suite_path.read_bytes()).hexdigest(), report assert Path(report["user_cfg"]).resolve() == user_cfg.resolve(), report assert Path(report["system_cfg"]).resolve() == system_cfg.resolve(), report report.update(native_exit_code=exit_code, native_pid=process.pid, engine_path=str(engine), report_path=str(report_path), execution_boundary="owned headless FreeCADCmd; parent missing/wrong targets remain GUI") return report def step_world_exchange_regression(output): """Actual product API, world poses and source/history preservation.""" import Import import re import qi_step_export def center(shape): return sum((solid.CenterOfMass * solid.Volume for solid in shape.Solids), App.Vector()) / sum(solid.Volume for solid in shape.Solids) def compare(expected, actual): assert expected.isValid() and actual.isValid() assert len(expected.Solids) == len(actual.Solids) > 0 _near(actual.Volume, expected.Volume); _near(actual.Area, expected.Area) for a, b in zip(center(actual), center(expected)): _near(a, b) bounds = lambda s: (s.BoundBox.XMin, s.BoundBox.YMin, s.BoundBox.ZMin, s.BoundBox.XMax, s.BoundBox.YMax, s.BoundBox.ZMax) for a, b in zip(bounds(actual), bounds(expected)): _near(a, b) _near(expected.cut(actual).Volume, 0); _near(actual.cut(expected).Volume, 0) def marker(doc): obj = doc.addObject("App::FeaturePython", "ExportHistory") obj.addProperty("App::PropertyString", "Text"); obj.Text = "before" doc.recompute(); doc.clearUndos() doc.openTransaction("Existing executable Redo") obj.Text = "after"; doc.commitTransaction(); doc.undo(); doc.recompute() App.setActiveDocument(doc.Name) assert doc.UndoCount == 0 and doc.RedoCount == 1 return obj def complete_state(doc): return _state(doc), App.ActiveDocument.Name if App.ActiveDocument else None, set(App.listDocuments()) def exchange(doc, expected, filename, objects=None): history = marker(doc); before = complete_state(doc) path = output / filename model.export_model(doc, path, objects=objects) assert complete_state(doc) == before, "STEP export changed source payload/history/context" imported = App.newDocument("STEPWorldReadback") try: Import.insert(str(path), imported.Name); imported.recompute() shapes = [Part.getShape(obj) for obj in imported.RootObjects] shapes = [shape for shape in shapes if not shape.isNull() and shape.Solids] assert len(shapes) == 1 compare(expected, shapes[0]) finally: App.closeDocument(imported.Name); App.setActiveDocument(doc.Name) assert complete_state(doc) == before doc.redo(); assert history.Text == "after"; doc.recompute() text = path.read_text(encoding="utf-8") return {"path": str(path), "solids": len(expected.Solids), "volume_mm3": expected.Volume, "world_com_mm": list(center(expected)), "source_exact_and_redo_executes": True, "products": re.findall(r"PRODUCT\('([^']*)','([^']*)'", text), "occurrences": re.findall(r"NEXT_ASSEMBLY_USAGE_OCCURRENCE\('[^']*','([^']*)'", text)} expected = [] for x, length, width, height in ((100, 2, 3, 4), (110, 2, 3, 4), (120, 10, 10, 10), (200, 2, 3, 4), (210, 2, 3, 4), (220, 10, 10, 10), (300, 2, 3, 4)): shape = Part.makeBox(length, width, height) shape.Placement = App.Placement(App.Vector(1000, 50 + x, 0), App.Rotation(App.Vector(0, 0, 1), 90)) expected.append(shape) expected = Part.makeCompound(expected) cases = [] # Separate fresh documents make both selection modes cold. No source # root.Shape or aggregate Part.getShape query precedes exchange's snapshot. for explicit in (False, True): mode = "explicit" if explicit else "default" doc, root, bolt, housing, sub, a, b, direct = nested_fixture("STEPWorldRegression" + mode, rotated=True, flexible=True) case = exchange(doc, expected, "placed-root-" + mode + ".step", [root] if explicit else None) cases.append(case) assert sum(item[0] == bolt.Label for item in case["products"]) == 1 assert sum(item[0] == housing.Label for item in case["products"]) == 1 assert {root.Label, a.Label, b.Label, direct.Label}.issubset(case["occurrences"]) part_doc = model.new_part("STEPParentBodyRegression") body = model.create_box(2, 3, 4, doc=part_doc) parent = part_doc.addObject("App::Part", "Definitions"); parent.addObject(body) parent.Placement = App.Placement(App.Vector(50, 60, 70), App.Rotation(App.Vector(0, 0, 1), 90)) body.Placement = App.Placement(App.Vector(10, 20, 30), App.Rotation(App.Vector(0, 0, 1), 30)) part_doc.recompute() expected = Part.makeBox(2, 3, 4) expected.Placement = App.Placement(App.Vector(30, 70, 100), App.Rotation(App.Vector(0, 0, 1), 120)) # The exact source snapshot in exchange precedes its first world-shape # query: cold native ElementMap work must not escape readonly validation. cases.extend([exchange(part_doc, expected, "placed-body-default.step"), exchange(part_doc, expected, "placed-body-explicit.step", [body])]) history = marker(part_doc); before = complete_state(part_doc) protected = output / "writer-protected.step"; protected.write_bytes(b"owned-old-export") existing = {path.name for path in output.iterdir()} def fault_after_native(objects, path, **kwargs): Import.export(objects, path, **kwargs) raise RuntimeError("Owned fault after native STEP writer") try: qi_step_export.export_step(part_doc, protected, _writer=fault_after_native) except RuntimeError as error: assert "Owned fault" in str(error) else: raise AssertionError("Native writer fault was hidden") assert protected.read_bytes() == b"owned-old-export" assert existing == {path.name for path in output.iterdir()} assert complete_state(part_doc) == before part_doc.redo(); assert history.Text == "after" return {"exchanges": cases, "native_writer_fault_old_bytes_and_source_exact": True, "native_instance_identity_labels_and_world_geometry_proved": True} def run(output_dir): output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) report_path = output / "bom-report.json" initial_docs = set(App.listDocuments()) active_name = App.ActiveDocument.Name if App.ActiveDocument else "" checks = [] def check(name, function): report_path.write_text(json.dumps({"running_case": name, "checks": checks}, ensure_ascii=False, indent=2), encoding="utf-8") App.Console.PrintLog("QInventor BOM test: " + name + "\n") try: checks.append({"name": name, "passed": True, "evidence": function()}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) def nested_counts_and_physical_mass(): doc, root, bolt, housing, sub, a, b, direct = nested_fixture() before = _state(doc) rows = bom.bom_rows(doc) assert len(rows) == 2, rows by_number = {row["part_number"]: row for row in rows} assert by_number["BOLT"]["quantity"] == 5 and by_number["HOUSING"]["quantity"] == 2 assert by_number["BOLT"]["quantity_total"] == 5 bolt_mass = 24e-9 * 7850 housing_mass = 1000e-9 * 2700 _near(by_number["BOLT"]["unit_mass_kg"], bolt_mass) _near(by_number["HOUSING"]["unit_mass_kg"], housing_mass) props = bom.mass_properties(root) summary = bom.bom_summary(doc) expected_mass = 5 * bolt_mass + 2 * housing_mass _near(props["mass_kg"], expected_mass); _near(props["volume_mm3"], 2120) _near(summary["mass_kg"], expected_mass) assert props["components"] == 7 and props["solids"] == 7 assert set(props["materials"]) == {"Сталь", "Алюминий"} assert props["material"] == "Смешанные материалы" # Independent physical calculation: the root rotates (x,y)->(-y,x) # and translates (1000,50,0); density makes geometric COM unsuitable. expected_x = (5 * bolt_mass * 998.5 + 2 * housing_mass * 995) / expected_mass expected_y = (bolt_mass * sum((151, 161, 251, 261, 351)) + housing_mass * (175 + 275)) / expected_mass expected_z = (5 * bolt_mass * 2 + 2 * housing_mass * 5) / expected_mass for actual, expected in zip(props["center_mm"], (expected_x, expected_y, expected_z)): _near(actual, expected) assert document_roots_equal(doc, [root]) assert _state(doc) == before, "Read-only BOM/mass changed persistence or history" # Instance visibility is a drawing concern, never a quantity filter. if App.GuiUp: a.Visibility = False; direct.Visibility = False assert {row["part_number"]: row["quantity"] for row in bom.bom_rows(doc)} == {"BOLT": 5, "HOUSING": 2} return {"flat": rows, "mass": props, "independent_world_com": [expected_x, expected_y, expected_z], "rigid_and_flexible_native_links": True, "read_only_persistence_history": True} def document_roots_equal(doc, expected): return bom.document_bom_roots(doc) == expected def structured_multiplicity_and_scope(): doc, root, bolt, housing, sub, a, b, direct = nested_fixture("BOMStructured", rotated=False) rows = bom.bom_rows(doc, "structured") assemblies = [row for row in rows if row["is_assembly"]] assert len(assemblies) == 1 and assemblies[0]["quantity"] == 2 and assemblies[0]["quantity_total"] == 2, rows parent = assemblies[0]["position"] children = [row for row in rows if row["parent_position"] == parent] child = {row["part_number"]: row for row in children} assert child["BOLT"]["quantity"] == 2 and child["BOLT"]["quantity_total"] == 4 assert child["HOUSING"]["quantity"] == 1 and child["HOUSING"]["quantity_total"] == 2 top_bolt = [row for row in rows if row["part_number"] == "BOLT" and not row["parent_position"]] assert len(top_bolt) == 1 and top_bolt[0]["quantity"] == 1 assert all(row["level"] == 1 for row in children) _near(sum(row["mass_kg"] for row in rows if not row["parent_position"]), bom.bom_summary(doc)["mass_kg"]) assert sum(row["mass_kg"] for row in rows) > bom.bom_summary(doc)["mass_kg"] selected = bom.bom_rows(doc, root=a) assert {row["part_number"]: row["quantity"] for row in selected} == {"BOLT": 2, "HOUSING": 1} _near(bom.bom_summary(doc, a)["mass_kg"], 2 * 24e-9 * 7850 + 1000e-9 * 2700) roots = bom.available_bom_roots(doc) assert roots == [root, a, b], [obj.Name for obj in roots] return {"rows": rows, "scoped_subassembly": selected, "summary_never_double_counts": True, "actual_scope_roots": [obj.Name for obj in roots]} def same_numbers_distinct_sources(): doc = model.new_part("BOMSourceIdentity") root = _assembly(doc, "Assembly") definitions = doc.addObject("App::Part", "Definitions") sources = [model.create_box(2, 3, 4, doc), model.create_box(4, 3, 4, doc)] for index, source in enumerate(sources): source.QIPartNumber = "SAME"; source.Label = "Одинаковая подпись" definitions.addObject(source) _link(root, source, "Occurrence" + str(index), index * 20) doc.recompute() rows = bom.bom_rows(doc) assert len(rows) == 2 and len({row["source_id"] for row in rows}) == 2 assert [row["quantity"] for row in rows] == [1, 1] assert [row["part_number"] for row in rows] == ["SAME", "SAME"] _near(sum(row["mass_kg"] for row in rows), (24 + 48) * 1e-9 * 7850) return {"rows": rows, "labels_and_numbers_do_not_define_identity": True} def native_arrays_scaling_hidden_and_mirror(): doc = model.new_part("BOMArray") root = _assembly(doc, "Assembly") source = doc.addObject("Part::Box", "Bolt") source.Length, source.Width, source.Height = 2, 3, 4 model.set_material(source, "Сталь") array = _link(root, source, "Array", 100) array.ShowElement = False array.ElementCount = 3 array.PlacementList = [App.Placement(App.Vector(x, 0, 0), App.Rotation()) for x in (0, 10, 20)] array.ScaleList = [App.Vector(factor, factor, factor) for factor in (1, 2, 3)] array.setElementVisible("1", False) root.Placement.Base = App.Vector(50, 0, 0) doc.recompute() props = bom.mass_properties(root) _near(props["volume_mm3"], 24 * (1 + 8 + 27)) assert props["components"] == 3 rows = bom.bom_rows(doc) assert len(rows) == 3 and sum(row["quantity"] for row in rows) == 3 assert len({row["variant_id"] for row in rows}) == 3 # Native aggregate array.Shape hides index1; actual occurrence mass # deliberately includes it through numeric subobject paths. assert array.Shape.Volume < props["volume_mm3"] mirror = _link(root, source, "Mirror", 200) mirror.Scale = -1 anisotropic = _link(root, source, "Anisotropic", 300) anisotropic.ScaleVector = App.Vector(2, 3, 4) doc.recompute() _near(bom.mass_properties(mirror)["volume_mm3"], 24) _near(bom.mass_properties(mirror)["center_mm"][0], 249) _near(bom.mass_properties(anisotropic)["volume_mm3"], 24 * 24) _near(bom.bom_summary(doc)["volume_mm3"], 24 * (36 + 1 + 24)) return {"virtual_hidden_array_elements_counted": True, "array_variants": rows, "mirrored_positive_physical_mass": True, "nonuniform_scaling": True} def external_docs_source_recompute_and_generic_container_links(): source_doc = model.new_part("BOMExternalSource") source = model.create_box(2, 3, 4, source_doc) model.set_material(source, "Алюминий") source_doc.saveAs(str(output / "external-source.FCStd")) doc = model.new_part("BOMExternalAssembly") root = _assembly(doc, "Assembly") doc.saveAs(str(output / "external-assembly.FCStd")) _link(root, source, "ExternalA", 10) _link(root, source, "ExternalB", 30) doc.recompute() roots = bom.available_bom_roots(doc) assert roots == [root] and all(obj.Document is doc for obj in roots) _near(bom.bom_summary(doc)["mass_kg"], 2 * 24e-9 * 2700) source.QIParameters.QILength = 4 # deliberately no source_doc.recompute _near(bom.bom_summary(doc)["volume_mm3"], 96) assert bom.bom_rows(doc)[0]["quantity"] == 2 generic = source_doc.addObject("App::Part", "Container") source_doc.addObject("App::Part", "UnusedSourceContainer") _link(generic, source, "NestedPart", 7) source_doc.recompute() alias = _link(root, generic, "ExternalContainer", 60) doc.recompute() rows = bom.bom_rows(doc) assert len(rows) == 1 and rows[0]["quantity"] == 3 roots = bom.available_bom_roots(doc) assert roots == [root, alias] and all(obj.Document is doc for obj in roots) _near(bom.mass_properties(alias)["volume_mm3"], 48) return {"external_dependency_recomputed": True, "generic_AppPart_link_traversed": True, "scope_selector_contains_only_addressable_local_occurrences": [obj.Name for obj in roots]} def expanded_array_material_and_owned_elements(): evidence = [] for expanded in (False, True): doc = model.new_part("BOMExpanded" if expanded else "BOMVirtualMaterial") root = _assembly(doc, "Assembly") source = doc.addObject("Part::Box", "Bolt") source.Length, source.Width, source.Height = 2, 3, 4 model.set_material(source, "Сталь") array = _link(root, source, "Array") array.ShowElement = expanded array.ElementCount = 3 model.set_material(array, "ABS") array.QIDensity = 1000 # explicit physical density independent of catalogue defaults doc.recompute() assert len(array.ElementList) == (3 if expanded else 0) assert bom.document_bom_roots(doc) == [root] before = _state(doc) rows = bom.bom_rows(doc) assert len(rows) == 1 and rows[0]["quantity"] == 3 and rows[0]["material"] == "ABS", rows _near(rows[0]["mass_kg"], 3 * 24e-9 * 1000) summary = bom.bom_summary(doc) assert summary["components"] == 3 _near(summary["volume_mm3"], 72) assert _state(doc) == before # Assembly-level metadata cannot replace the leaf's density. model.set_material(root, "Алюминий") assert bom.bom_rows(doc)[0]["material"] == "ABS" evidence.append({"expanded_elements": expanded, "rows": rows, "components": summary["components"], "actual_material_service_used": True, "array_view_properties": list(array.ViewObject.PropertiesList) if App.GuiUp else [], "assembly_view_properties": list(root.ViewObject.PropertiesList) if App.GuiUp else []}) return {"both_native_array_representations": evidence, "parent_array_material_used": True, "expanded_elements_not_extra_document_roots": True, "container_density_does_not_replace_leaf_density": True} def effective_scale_grouping_and_rootless_transform(): doc = model.new_part("BOMEffectiveScale") root = _assembly(doc, "Assembly") source = doc.addObject("Part::Box", "Bolt") source.Length, source.Width, source.Height = 2, 3, 4 inner = doc.addObject("App::Link", "Inner") inner.setLink(source); inner.Scale = .5 outer = _link(root, inner, "Outer", 10) outer.Scale = 2 direct = _link(root, source, "Direct", 30) doc.recompute() rows = bom.bom_rows(doc) assert len(rows) == 1 and rows[0]["quantity"] == 2, rows _near(rows[0]["unit_mass_kg"], 24e-9 * 7850) # Rootless Part.getShape omits baked scale from its returned matrix; # native getLinkedObject resolves it without raw-chain oversplitting. root.removeObject(outer) root.removeObject(direct) doc.removeObject(root.Name) doc.recompute() rootless_rows = bom.bom_rows(doc) assert len(rootless_rows) == 1 and rootless_rows[0]["quantity"] == 2, rootless_rows _near(bom.mass_properties(outer)["volume_mm3"], 24) mirrored = doc.addObject("App::Link", "Mirrored") mirrored.setLink(source); mirrored.Scale = -1 doc.recompute() rows = bom.bom_rows(doc) assert sorted(row["quantity"] for row in rows) == [1, 2], rows _near(bom.bom_summary(doc)["volume_mm3"], 72) return {"uniform_scale_chain_cancels": True, "rootless_effective_transform": True, "mirror_variant_separate": True, "rows": rows} def container_history_mirroring_and_helper_groups(): doc = model.new_part("BOMContainerHistory") root = _assembly(doc, "Assembly") base = doc.addObject("Part::Box", "Base") tool = doc.addObject("Part::Box", "Tool") for obj in (base, tool): obj.Length, obj.Width, obj.Height = 10, 10, 10 root.addObject(obj) tool.Placement.Base = App.Vector(5, 0, 0) fused = doc.addObject("Part::Fuse", "Final") fused.Base, fused.Tool = base, tool root.addObject(fused) helpers = doc.addObject("App::DocumentObjectGroup", "Helpers") helpers.addObject(doc.addObject("Sketcher::SketchObject", "HelperSketch")) doc.recompute() rows = bom.bom_rows(doc) assert len(rows) == 1 and rows[0]["source_name"] == "Final", rows _near(bom.bom_summary(doc)["volume_mm3"], 1500) structured = bom.bom_rows(doc, "structured") assert len(structured) == 1 and structured[0]["source_name"] == "Final", structured _link(root, base, "ActualBaseOccurrence", 40) doc.recompute() assert {row["source_name"] for row in bom.bom_rows(doc)} == {"Base", "Final"} _near(bom.bom_summary(doc)["volume_mm3"], 2500) mirror_doc = model.new_part("BOMNativeMirrorFeature") source = mirror_doc.addObject("Part::Box", "Source") source.Length, source.Width, source.Height = 2, 3, 4 mirror = mirror_doc.addObject("Part::Mirroring", "Mirror") mirror.Source = source; mirror.Normal = App.Vector(1, 0, 0) mirror_doc.recompute() assert bom.document_bom_roots(mirror_doc) == [mirror] assert len(bom.bom_rows(mirror_doc)) == 1 _near(bom.bom_summary(mirror_doc)["volume_mm3"], 24) return {"native_inputs_not_counted_inside_container": True, "actual_source_link_counted": True, "helper_sketch_group_omitted": True, "native_Mirroring_Source_consumed": True} def history_boolean_consumed_and_density_units(): doc = model.new_part("BOMBoolean") base = doc.addObject("Part::Box", "Box") base.Length, base.Width, base.Height = 10, 10, 10 tool = doc.addObject("Part::Box", "Tool") tool.Length, tool.Width, tool.Height = 5, 10, 10 cut = doc.addObject("Part::Cut", "Cut") cut.Base, cut.Tool = base, tool cut.addProperty("App::PropertyDensity", "QIDensity") cut.QIDensity = App.Units.Quantity("2700 kg/m^3") cut.addProperty("App::PropertyString", "QIMaterial"); cut.QIMaterial = "Алюминий" doc.recompute() assert bom.document_bom_roots(doc) == [cut] rows = bom.bom_rows(doc) assert len(rows) == 1 and rows[0]["quantity"] == 1 and rows[0]["source_name"] == "Cut" _near(rows[0]["mass_kg"], 500e-9 * 2700) assert bom.mass_properties(cut)["density_kg_m3"] > 2699 root = _assembly(doc, "Assembly") _link(root, cut, "CutOccurrence") _link(root, base, "SeparateBase", 20) doc.recompute() rows = bom.bom_rows(doc) assert len(rows) == 2 and {row["source_name"] for row in rows} == {"Box", "Cut"} return {"Boolean_base_and_tool_are_not_independent_parts": True, "explicit_base_occurrence_counted": True, "native_density_quantity_converted_to_kg_m3": True} def cached_failure_density_invalid_and_scoped_roots(): doc = model.new_part("BOMInvalidScope") good = model.create_box(2, 3, 4, doc) bad = model.create_box(5, 6, 7, doc) pad = bad.Tip pad.setExpression("Length", None); pad.Length = 0 doc.recompute() assert "Invalid" in pad.State and pad.Shape.isValid() and pad.Shape.Solids _reject(lambda: bom.bom_rows(doc)) rows = bom.bom_rows(doc, root=good) assert len(rows) == 1 _near(bom.bom_summary(doc, good)["volume_mm3"], 24) _reject(lambda: bom.mass_properties(bad)) for density in (0, -1, float("nan"), float("inf")): good.QIDensity = density _reject(lambda: bom.mass_properties(good)) good.QIDensity = 7850 _near(bom.mass_properties(good)["mass_kg"], 24e-9 * 7850) mixed = doc.addObject("Part::Feature", "MixedWireSolid") mixed.Shape = Part.makeCompound([Part.makeBox(1, 1, 1), Part.makeLine(App.Vector(), App.Vector(2, 2, 2))]) _reject(lambda: bom.mass_properties(mixed)) return {"cached_valid_BRep_invalid_feature_rejected": True, "explicit_scope_ignores_unrelated_failed_body": True, "invalid_density_rejected": 4, "wire_solid_compound_rejected": True} def missing_target_and_native_cycles(): doc = model.new_part("BOMMissing") root = _assembly(doc, "Assembly") missing = doc.addObject("App::Link", "Missing") root.addObject(missing) _reject(lambda: bom.bom_rows(doc)) _reject(lambda: bom.mass_properties(root)) root.removeObject(missing) doc.removeObject(missing.Name) box = doc.addObject("Part::Box", "Box") wrong = doc.addObject("Assembly::AssemblyLink", "WrongAssembly") wrong.LinkedObject = box; root.addObject(wrong) _reject(lambda: bom.bom_rows(doc)) wrong.LinkedObject = None; root.removeObject(wrong); doc.removeObject(wrong.Name) generic = doc.addObject("App::Part", "GenericPart") generic.addObject(box) wrong = doc.addObject("Assembly::AssemblyLink", "WrongGenericAssembly") wrong.LinkedObject = generic; root.addObject(wrong) doc.recompute() assert wrong.Group == [] and "Shape" not in wrong.PropertiesList _reject(lambda: bom.bom_rows(doc)) wrong.LinkedObject = None; root.removeObject(wrong); doc.removeObject(wrong.Name) cycle_evidence = _owned_cycle_child(output) if App.GuiUp else _native_cycle_checks() return {"missing_and_wrong_assembly_target_rejected": True, "missing_wrong_target_gui_exercised": bool(App.GuiUp), "native_cycle_evidence": cycle_evidence} def csv_validation_atomic_and_empty(): doc, root, bolt, housing, sub, a, b, direct = nested_fixture("BOMCSV", rotated=False) flat = output / "nested-flat.csv" structured = output / "nested-structured.csv" bom.export_bom(doc, flat) bom.export_bom(doc, structured, "structured") with flat.open(encoding="utf-8-sig", newline="") as stream: rows = list(csv.DictReader(stream, delimiter=";")) assert len(rows) == 2 and {row["Обозначение"]: int(row["Количество"]) for row in rows} == {"BOLT": 5, "HOUSING": 2} with structured.open(encoding="utf-8-sig", newline="") as stream: hierarchy = list(csv.DictReader(stream, delimiter=";")) assert any(row["Родитель"] and int(row["Всего"]) == 4 for row in hierarchy) old = flat.read_bytes() bolt.QIDensity = 0 _reject(lambda: bom.export_bom(doc, flat)) assert flat.read_bytes() == old missing_directory = output / "rejected-export-parent" _reject(lambda: bom.export_bom(doc, missing_directory / "invalid.csv")) assert not missing_directory.exists() empty = model.new_part("BOMEmpty") assert bom.bom_rows(empty) == [] and bom.bom_summary(empty)["mass_kg"] == 0 _reject(lambda: bom.export_bom(empty, flat)) assert flat.read_bytes() == old empty_root = _assembly(empty, "EmptyAssembly") _reject(lambda: bom.mass_properties(empty_root)) return {"flat_csv": str(flat), "structured_csv": str(structured), "old_bytes_preserved_on_failure": True, "no_parent_directory_on_invalid_input": True, "empty_export_rejected": True} def fcstd_reopen_source_edit_and_step(): doc, root, bolt, housing, sub, a, b, direct = nested_fixture("BOMPersistence", rotated=False) file = output / "nested-assembly.FCStd" model.save_project(doc, file) name = doc.Name App.closeDocument(name) doc = App.openDocument(str(file)) doc.UndoMode = 1 # native GUI enables history; headless reopen does not root = doc.getObject("Assembly") rows = bom.bom_rows(doc) assert {row["part_number"]: row["quantity"] for row in rows} == {"BOLT": 5, "HOUSING": 2} _near(bom.mass_properties(root)["mass_kg"], 0.006342) bolt = next(obj for obj in doc.Objects if getattr(obj, "QIPartNumber", "") == "BOLT") model.set_dimensions(bolt, {"QILength": 4}) props = bom.mass_properties(root) _near(props["volume_mm3"], 2240) _near(props["mass_kg"], 5 * 48e-9 * 7850 + 2 * 1000e-9 * 2700) doc.undo(); doc.recompute() _near(bom.mass_properties(root)["volume_mm3"], 2120) doc.redo(); doc.recompute() _near(bom.mass_properties(root)["volume_mm3"], 2240) step = output / "nested-assembly.step" model.export_model(doc, step) shape = Part.read(str(step)) assert shape.isValid() and len(shape.Solids) == 7 _near(shape.Volume, 2240) centers = sorted((round(s.CenterOfMass.x, 6), round(s.CenterOfMass.y, 6), round(s.CenterOfMass.z, 6)) for s in shape.Solids) assert centers == [(102.0, 1.5, 2.0), (112.0, 1.5, 2.0), (125.0, 5.0, 5.0), (202.0, 1.5, 2.0), (212.0, 1.5, 2.0), (225.0, 5.0, 5.0), (302.0, 1.5, 2.0)], centers return {"fcstd": str(file), "step": str(step), "step_leaf_solids": 7, "step_volume_mm3": shape.Volume, "source_edit_and_undo_redo_updates_all_occurrences": True, "step_world_centers": centers} functions = [("STEP world frames readonly payload shared identities and atomic writer", lambda: step_world_exchange_regression(output)), ("Nested rigid/flexible quantities density and world COM", nested_counts_and_physical_mass), ("Structured local/cumulative multiplicity and explicit roots", structured_multiplicity_and_scope), ("CAD source identity independent of labels and part numbers", same_numbers_distinct_sources), ("Native hidden arrays mirror and nonuniform scales", native_arrays_scaling_hidden_and_mirror), ("Expanded and virtual arrays preserve materials without duplicate roots", expanded_array_material_and_owned_elements), ("Effective scale cancellation rootless transforms and mirror identity", effective_scale_grouping_and_rootless_transform), ("Container Boolean history native mirroring and helper groups", container_history_mirroring_and_helper_groups), ("External source recompute and generic native container links", external_docs_source_recompute_and_generic_container_links), ("Consumed Boolean history and native density units", history_boolean_consumed_and_density_units), ("Cached native feature errors density and scoped validation", cached_failure_density_invalid_and_scoped_roots), ("Missing targets and native link/assembly cycles", missing_target_and_native_cycles), ("Atomic CSV validation old bytes and empty scope", csv_validation_atomic_and_empty), ("FCStd source edits Undo/Redo and STEP instance proof", fcstd_reopen_source_edit_and_step)] try: for name, function in functions: check(name, function) finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) if active_name and active_name in App.listDocuments(): App.setActiveDocument(active_name) report = {"passed": all(item["passed"] for item in checks), "count": len(checks), "checks": checks, "summary": {"total": len(checks), "passed": sum(item["passed"] for item in checks), "failed": sum(not item["passed"] for item in checks)}, "gui_exercised": bool(App.GuiUp), "cycle_execution_boundary": "owned headless FreeCADCmd child" if App.GuiUp else "current headless native process"} report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "--cycles-only": result = run_cycles_only(sys.argv[2]) else: result = run(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] / "artifacts" / "bom-kernel") print(json.dumps(result.get("summary", {"passed": result["passed"]}))) raise SystemExit(0 if result["passed"] else 1)