Files

755 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)