755 lines
40 KiB
Python
755 lines
40 KiB
Python
"""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)
|