"""Native parameter preflight fidelity, real CAD inputs and drawing outputs. Intended location: tests/parameter_preflight_suite.py. Fixtures are native and self-contained; persistence controls run in an owned fresh FreeCADCmd process. """ import hashlib import io import json import math import os from pathlib import Path import subprocess import sys import tempfile import time import traceback import zipfile import FreeCAD as App import Part import Sketcher ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src" / "QInventor")) import qi_model as model import qi_parameters as parameters import qi_drawing_bom as drawing_bom def _source_hashes(): return {path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for folder in ("src", "tests", "scripts") for path in sorted((ROOT / folder).rglob("*")) if path.is_file() and not {"__pycache__", "bin", "obj"}.intersection(path.parts) and path.suffix.lower() != ".pyc"} def _near(actual, expected): assert math.isclose(float(getattr(actual, "Value", actual)), expected, rel_tol=1e-9, abs_tol=1e-7), (actual, expected) def _payload(obj): raw = bytes(obj.dumpContent()) if not zipfile.is_zipfile(io.BytesIO(raw)): return raw with zipfile.ZipFile(io.BytesIO(raw)) as archive: return tuple((name, archive.read(name)) for name in sorted(archive.namelist())) def _state(doc): return ([(obj.Name, obj.TypeId, _payload(obj), list(obj.ExpressionEngine), list(obj.State)) for obj in doc.Objects], doc.UndoCount, doc.RedoCount, App.getActiveTransaction(), App.ActiveDocument.Name if App.ActiveDocument else None, set(App.listDocuments())) def _invalid(doc): return {obj.Name: obj.getStatusString() for obj in doc.Objects if any(f in obj.State for f in ("Invalid", "Error"))} def _reject(doc, callback, message_part=None): before = _state(doc) try: callback() except ValueError as exc: message = str(exc) assert any("а" <= c.lower() <= "я" for c in message), message if message_part: assert message_part in message.lower(), message else: raise AssertionError("Unsafe parameter batch accepted") assert _state(doc) == before, "Rejected batch changed native payload/state, Undo/Redo, active operation/document or document set" return {"message": message, "original_exact_unchanged": True, "undo": doc.UndoCount, "redo": doc.RedoCount} def _rectangle(body, width, height, name="Profile", x=0, y=0): sketch = body.newObject("Sketcher::SketchObject", name) points = [App.Vector(x, y, 0), App.Vector(x + width, y, 0), App.Vector(x + width, y + height, 0), App.Vector(x, y + height, 0)] for i in range(4): sketch.addGeometry(Part.LineSegment(points[i], points[(i + 1) % 4]), False) sketch.addConstraint(Sketcher.Constraint("Block", i)) body.Document.recompute() assert sketch.solve() == 0 return sketch def _revolution(doc, body, profile, kind, angle): feature = body.newObject("PartDesign::" + kind, kind) feature.Profile = profile feature.ReferenceAxis = (profile, ["V_Axis"]) feature.Reversed = False; feature.Midplane = False; feature.Angle = angle body.Tip = feature doc.recompute() assert not _invalid(doc), _invalid(doc) return feature def _support_fixture(name): doc = App.newDocument(name); doc.UndoMode = 1 body = doc.addObject("PartDesign::Body", "Body") profile = _rectangle(body, 8, 20) revolution = _revolution(doc, body, profile, "Revolution", 180) cut = _rectangle(body, 4, 4, "CutProfile", 6, 8) body.Tip = revolution groove = _revolution(doc, body, cut, "Groove", 360) faces = [(i + 1, face) for i, face in enumerate(groove.Shape.Faces) if isinstance(face.Surface, Part.Plane) and abs(face.CenterOfMass.y - 20) < 1e-7 and abs(face.BoundBox.YLength) < 1e-7] assert len(faces) == 1 attached = body.newObject("Sketcher::SketchObject", "FaceProfile") attached.AttachmentSupport = (groove, ["Face" + str(faces[0][0])]) attached.MapMode = "FlatFace" doc.recompute() center = attached.Placement.inverse().multVec(App.Vector(0, 20, 0)) _near(center.z, 0) attached.addGeometry(Part.Circle(center, App.Vector(0, 0, 1), 2), False) attached.addConstraint(Sketcher.Constraint("Block", 0)) doc.recompute(); assert attached.solve() == 0 pocket = body.newObject("PartDesign::Pocket", "Pocket") pocket.Profile = attached; pocket.Length = 3 doc.recompute() assert pocket.BaseFeature is groove and body.Tip is pocket _near(pocket.Shape.Volume, 578 * math.pi) _near(pocket.Shape.Area, 250 * math.pi + 292) definitions = doc.addObject("App::Part", "Definitions"); definitions.addObject(body) definitions.Placement = App.Placement(App.Vector(100, 200, 300), 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)) assembly = doc.addObject("Assembly::AssemblyObject", "Assembly"); assembly.Type = "Assembly" assembly.newObject("Assembly::JointGroup", "Joints") assembly.Placement = App.Placement(App.Vector(1000, 50, 40), App.Rotation(App.Vector(0, 0, 1), 90)) for name_, x, angle in (("OccurrenceA", 0, 0), ("OccurrenceB", 100, 180)): link = doc.addObject("App::Link", name_); link.setLink(body); link.LinkTransform = True link.LinkPlacement = App.Placement(App.Vector(x, 0, 0), App.Rotation(App.Vector(0, 0, 1), angle)) assembly.addObject(link) doc.recompute(); assert not _invalid(doc), _invalid(doc) _near(Part.getShape(assembly).Volume, 2 * 578 * math.pi) doc.clearUndos(); doc.openTransaction("Native occurrence placement branch") doc.OccurrenceB.LinkPlacement = App.Placement(App.Vector(110, 0, 0), App.Rotation(App.Vector(0, 0, 1), 180)) doc.recompute(); doc.commitTransaction(); doc.undo(); doc.recompute() assert doc.UndoCount == 0 and doc.RedoCount == 1 App.setActiveDocument(doc.Name) assert any(b'shadow="' in content for filename, content in _payload(attached) if filename == "Persistence.xml") return doc def _physical_shapes(doc): return {"revolution": doc.Revolution.Shape.copy(), "groove": doc.Groove.Shape.copy(), "pocket": doc.Pocket.Shape.copy(), "body_local": doc.Body.Shape.copy(), "body_world": Part.getShape(doc.Definitions, "Body.").copy(), "occurrence_a": Part.getShape(doc.Assembly, "OccurrenceA.").copy(), "occurrence_b": Part.getShape(doc.Assembly, "OccurrenceB.").copy(), "assembly_world": Part.getShape(doc.Assembly, "").copy()} def _structure(doc): def link(value): if value is None: return None if isinstance(value, (list, tuple)): return [link(v) for v in value] return value.Name if hasattr(value, "Document") and hasattr(value, "Name") else str(value) result = {} for obj in doc.Objects: fields = {"type": obj.TypeId, "expressions": list(obj.ExpressionEngine)} for prop in ("Tip", "BaseFeature", "Profile", "Support", "AttachmentSupport", "ReferenceAxis", "LinkedObject", "Group"): if prop in obj.PropertiesList: fields[prop] = link(getattr(obj, prop)) for prop in ("Placement", "AttachmentOffset", "LinkPlacement"): if prop in obj.PropertiesList: placement = getattr(obj, prop) fields[prop] = [list(placement.Base), list(placement.Rotation.Q)] for prop in ("Angle", "Length"): if prop in obj.PropertiesList: fields[prop] = float(getattr(obj, prop)) for prop in ("MapMode", "Reversed", "LinkTransform"): if prop in obj.PropertiesList: fields[prop] = str(getattr(obj, prop)) result[obj.Name] = fields return result def _compare_shapes(expected, actual): assert set(actual) == set(expected) def solid_center(shape): volume = sum(float(solid.Volume) for solid in shape.Solids) assert math.isfinite(volume) and volume > 0 return sum((solid.CenterOfMass * float(solid.Volume) for solid in shape.Solids), App.Vector()) / volume results = {} for key, before in expected.items(): after = actual[key] assert before.isValid() and after.isValid() assert len(before.Solids) == len(after.Solids) > 0 _near(after.Volume, before.Volume); _near(after.Area, before.Area) after_center, before_center = solid_center(after), solid_center(before) for a, b in zip(after_center, before_center): _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(after), bounds(before)): _near(a, b) a_only, b_only = before.cut(after), after.cut(before) _near(sum(s.Volume for s in a_only.Solids), 0) _near(sum(s.Volume for s in b_only.Solids), 0) results[key] = {"volume_mm3": after.Volume, "area_mm2": after.Area, "bounds_mm": bounds(after), "volume_weighted_solid_com_mm": list(after_center), "bidirectional_boolean_difference_volume_mm3": [a_only.Volume, b_only.Volume]} return results def _compare_persisted_structure(expected, actual): # FCStd quaternion serialization changes last-bit precision. Object/link # identities, formulas and every non-Placement field remain exact; live # Undo comparisons continue using the original exact structure equality. assert set(expected) == set(actual), "Persisted native object set changed" rounding = [] for name, fields in expected.items(): assert set(fields) == set(actual[name]), (name, fields, actual[name]) for prop, value in fields.items(): restored = actual[name][prop] if prop not in ("Placement", "AttachmentOffset", "LinkPlacement"): assert restored == value, (name, prop, value, restored) continue assert len(value) == len(restored) == 2 and len(value[0]) == len(restored[0]) == 3 assert len(value[1]) == len(restored[1]) == 4 for component, tolerance in ((0, 1e-9), (1, 1e-12)): for index, (before, after) in enumerate(zip(value[component], restored[component])): assert math.isfinite(before) and math.isfinite(after) assert math.isclose(before, after, rel_tol=0, abs_tol=tolerance), (name, prop, component, index, before, after) if before != after: rounding.append({"object": name, "property": prop, "component": component, "index": index, "expected": before, "actual": after, "absolute_delta": abs(after - before)}) for quaternion in (value[1], restored[1]): assert math.isclose(sum(v * v for v in quaternion), 1, rel_tol=0, abs_tol=1e-12), quaternion return {"serialized_placement_rounding": rounding, "base_absolute_tolerance_mm": 1e-9, "quaternion_absolute_tolerance": 1e-12, "all_nonplacement_fields_exact": True} def _height_row(body): rows = [r for r in parameters.list_document_parameters(body.Document) if r["key"] == body.QIParameters.Name + ":QIHeight"] assert len(rows) == 1 and rows[0]["editable"], rows return rows[0] def _cell(sheet, address="H1"): present = address in sheet.PropertiesList return {"present": present, "type": sheet.getTypeIdOfProperty(address) if present else None, "value": sheet.get(address) if present else None, "formula": sheet.getContents(address)} def _assert_float_cell(sheet, value, formula): current = _cell(sheet) assert current["present"] and current["type"] == "App::PropertyFloat", current _near(current["value"], value) assert current["formula"] == formula, current return current def _cad_table(body, page, height): doc = body.Document; row = _height_row(body) _near(row["value"], height); _near(body.Shape.Volume, 6 * height) assert body.Shape.isValid() and len(body.Shape.Solids) == 1 and not _invalid(doc), _invalid(doc) result = {"parameter_key": row["key"], "height_mm": row["value"], "volume_mm3": body.Shape.Volume} if page is not None: view = drawing_bom.table_for_page(page); sheet = view.Source; data = json.loads(sheet.QIBOMData) _near(data["summary"]["mass_kg"], 6 * height * 7850 / 1e9) assert sheet.TypeId == "Spreadsheet::SheetPython" and sheet.QIBOMType == "QInventor.DrawingBOM.1" assert not sheet.QIBOMError and all(sheet.getContents(c) == v for c, v in data["cells"].items()) result.update(mass_kg=data["summary"]["mass_kg"], cells=data["cells"], actual_mass_cell=sheet.getContents("F2")) return result def _bom_fixture(name): doc = App.newDocument(name); doc.UndoMode = 1 body = model.create_box(2, 3, 4, doc); body.QIPartNumber = "PARAM-BOX" model.set_material(body, "Сталь") page = model.create_drawing(doc); drawing_bom.create_bom_table(page); model.prepare_drawing(page) _near(page.Scale, 1) return doc, body, page def _direct_dxf(page, output, name, height): path = output / (name + ".dxf") model.export_drawing(page, path) # deliberately no caller-side prepare lines = path.read_text(encoding="utf-8").splitlines(); assert len(lines) % 2 == 0 pairs = [(int(lines[i].strip()), lines[i + 1]) for i in range(0, len(lines), 2)] entities, current, active = [], None, False for i, (code, value) in enumerate(pairs): if code == 0 and value == "SECTION": active = i + 1 < len(pairs) and pairs[i + 1] == (2, "ENTITIES") if active and code == 0: if current: entities.append(current) current = None if value == "ENDSEC" else [(code, value)] if value == "ENDSEC": active = False elif active and current is not None: current.append((code, value)) texts = [dict(e).get(1, "") for e in entities if e[0] == (0, "TEXT")] sheet = drawing_bom.table_for_page(page).Source for cell in ("F2", "F3"): assert sheet.getContents(cell).lstrip("'") in texts, texts points = [] for entity in entities: values = dict(entity) if values.get(0) == "LINE" and values.get(8) == "FrontView": points.extend([(float(values[10]), float(values[20])), (float(values[11]), float(values[21]))]) assert points, "Native front projection omitted from DXF" width = max(p[0] for p in points) - min(p[0] for p in points) actual_height = max(p[1] for p in points) - min(p[1] for p in points) _near(width, 2); _near(actual_height, height) return {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "texts": texts, "actual_native_front_width_mm": width, "actual_native_front_height_mm": actual_height} def reopen_check(directory, output): directory, output = Path(directory), Path(output) before = _source_hashes(); initial_docs = set(App.listDocuments()) report = {"passed": False, "gui_up": bool(App.GuiUp), "parameter_module": parameters.__file__, "parameter_module_sha256": hashlib.sha256(Path(parameters.__file__).read_bytes()).hexdigest(), "checks": []} try: assert not App.GuiUp doc = App.openDocument(str(directory / "valid-reference-retaining.FCStd")); doc.UndoMode = 1 expected = json.loads((directory / "valid-reference-structure.json").read_text(encoding="utf-8")) doc.recompute() persisted_structure = _compare_persisted_structure(expected, _structure(doc)) assert not _invalid(doc) shapes = {} for key in _physical_shapes(doc): shape = Part.Shape(); shape.read(str(directory / ("valid-reference-" + key + ".brep"))); shapes[key] = shape proof = _compare_shapes(shapes, _physical_shapes(doc)) parameters.apply_document_parameters(doc, [{"key": "Pocket:Length", "value": "2 mm", "expression": ""}]) _near(doc.Pocket.Shape.Volume, 580 * math.pi); assert not _invalid(doc) assert doc.FaceProfile.AttachmentSupport[0][0] is doc.Groove and doc.Body.Tip is doc.Pocket report["checks"].append({"name": "persistent_support_native_history_and_reedit", "passed": True, "structure": persisted_structure, "shapes": proof, "actual_reedit_volume_mm3": doc.Pocket.Shape.Volume}) App.closeDocument(doc.Name) doc = App.openDocument(str(directory / "valid-270.FCStd")); doc.UndoMode = 1 doc.recompute(); _near(doc.Revolution.Angle, 270); _near(doc.Revolution.Shape.Volume, 225 * math.pi) parameters.apply_document_parameters(doc, [{"key": "Revolution:Angle", "value": "90 deg", "expression": ""}]) _near(doc.Revolution.Shape.Volume, 75 * math.pi); assert not _invalid(doc) report["checks"].append({"name": "valid270_native90_reedit", "passed": True, "volume_mm3": doc.Revolution.Shape.Volume}) App.closeDocument(doc.Name) doc = App.openDocument(str(directory / "real-user-sheet.FCStd")); doc.UndoMode = 1; doc.recompute() _cad_table(doc.Body, None, 8) parameters.apply_document_parameters(doc, [{"key": _height_row(doc.Body)["key"], "expression": "3 * UserInput.Height"}]) data = _cad_table(doc.Body, None, 12) expected = json.loads((directory / "real-user-sheet-contents.json").read_text(encoding="utf-8")) assert doc.UserInput.getContents("A1") == expected["A1"] and doc.UserInput.getAlias("A1") == expected["alias"] report["checks"].append({"name": "ordinary_user_sheet_real_input", "passed": True, "cad": data}) App.closeDocument(doc.Name) doc = App.openDocument(str(directory / "parameter-drawing-bom.FCStd")); doc.UndoMode = 1; doc.recompute() body, page = doc.Body, doc.Drawing restored = _cad_table(body, page, 8) parameters.apply_document_parameters(doc, [{"key": _height_row(body)["key"], "value": 6}]) immediate = _cad_table(body, page, 6) exported = _direct_dxf(page, directory, "restored-height6-direct", 6) report["checks"].append({"name": "parameters_after_BOM_actual_reedit_and_direct_export", "passed": True, "restored": restored, "immediate": immediate, "direct_DXF": exported}) report["passed"] = True except Exception: report["error"] = traceback.format_exc() finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) report["source_stable"] = before == _source_hashes() report["passed"] = report["passed"] and report["source_stable"] output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report def _reopen_child(directory): child = Path(tempfile.mkdtemp(prefix="parameter-preflight-reopen-", dir=str(directory))) macro, report_path = child / "reopen.FCMacro", child / "report.json" macro.write_text("import sys\nsys.path.insert(0," + repr(str(Path(__file__).parent)) + ")\n" "import parameter_preflight_suite\nparameter_preflight_suite.reopen_check(" + repr(str(directory)) + "," + repr(str(report_path)) + ")\n", encoding="utf-8") engine = Path(App.getHomePath()) / "bin" / "FreeCADCmd.exe" args = [str(engine), "--user-cfg", str(child / "user.cfg"), "--system-cfg", str(child / "system.cfg"), "--log-file", str(child / "native.log"), str(macro)] environment = os.environ.copy(); environment.pop("QI_TEST", None) 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 (child / "process.log").open("wb") as stream: process = subprocess.Popen(args, cwd=str(child), env=environment, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, **options) deadline = time.monotonic() + 120 while True: try: code = process.wait(timeout=5) break except subprocess.TimeoutExpired: if time.monotonic() > deadline: process.kill(); process.wait(timeout=10) raise AssertionError("Owned native FCStd reopen timed out: " + str(child)) assert code == 0 and report_path.is_file(), (code, str(child)) report = json.loads(report_path.read_text(encoding="utf-8")) assert report["passed"] and not report["gui_up"] and len(report["checks"]) == 4, report assert report["parameter_module_sha256"] == hashlib.sha256(Path(parameters.__file__).read_bytes()).hexdigest(), report report.update(native_exit_code=code, native_pid=process.pid, report_path=str(report_path)) return report def _canonical_expression_history(): """No-op is native formula identity; equal numerical results are not enough.""" doc = App.newDocument("ParameterCanonicalNoop"); doc.UndoMode = 1 source = doc.addObject("App::VarSet", "QIUserParameters") source.addProperty("App::PropertyBool", "QIUserParameterSet"); source.QIUserParameterSet = True for name in ("Size", "OtherSize"): source.addProperty("App::PropertyDistance", name); setattr(source, name, 10) body = doc.addObject("PartDesign::Body", "Body") box = body.newObject("PartDesign::AdditiveBox", "Box") box.Width = 3; box.Height = 4; box.setExpression("Length", "QIUserParameters.Size") doc.recompute(); assert not _invalid(doc) and body.Tip is box _near(body.Shape.Volume, 120) doc.clearUndos(); doc.openTransaction("Independent canonical no-op Redo") body.Placement.Base.x = 11; doc.recompute(); doc.commitTransaction(); doc.undo(); doc.recompute() assert doc.UndoCount == 0 and doc.RedoCount == 1 sentinel = App.newDocument("ParameterCanonicalActiveSentinel"); App.setActiveDocument(sentinel.Name) before = _state(doc) parameters.apply_document_parameters(doc, [{"key": "Box:Length", "expression": doc.Name + "#QIUserParameters.Size"}]) assert _state(doc) == before, "Equivalent native formula changed persisted payload or document history" doc.redo(); doc.recompute(); _near(body.Placement.Base.x, 11) doc.undo(); doc.recompute() for expression in ("QIUserParameters.OtherSize", "QIUserParameters.Size * 1"): before_undo = doc.UndoCount parameters.apply_document_parameters(doc, [{"key": "Box:Length", "expression": expression}]) assert dict(box.ExpressionEngine)["Length"] == expression assert doc.UndoCount == before_undo + 1 and doc.RedoCount == 0 _near(box.Length, 10); _near(body.Shape.Volume, 120) doc.undo(); doc.recompute(); assert dict(box.ExpressionEngine)["Length"] == "QIUserParameters.Size" doc.redo(); doc.recompute(); assert dict(box.ExpressionEngine)["Length"] == expression doc.undo(); doc.recompute() rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, [{ "key": "Box:Length", "expression": doc.Name + "#MissingCanonicalSource.Size"}])) assert doc.RedoCount == 1 parameters.apply_document_parameters(doc, [{"key": "Box:Length", "expression": doc.Name + "#QIUserParameters.Size", "comment": "Комментарий при неизменной формуле"}]) assert doc.UndoCount == 1 and doc.RedoCount == 0 rows = {row["key"]: row for row in parameters.list_document_parameters(doc)} assert rows["Box:Length"]["comment"] == "Комментарий при неизменной формуле" assert dict(box.ExpressionEngine)["Length"] == "QIUserParameters.Size" doc.undo(); doc.recompute() rows = {row["key"]: row for row in parameters.list_document_parameters(doc)} assert rows["Box:Length"]["comment"] != "Комментарий при неизменной формуле" return {"qualified_canonical_noop_exact_payload_history_active_document": True, "independent_original_redo_executed": True, "equal_value_other_dependency_and_arithmetic_are_real_edits": True, "actual_comment_change_is_edit": True, "rejected_formula_exact_state": rejection} def run(output_dir): output = Path(output_dir).absolute(); output.mkdir(parents=True, exist_ok=True) initial_docs = set(App.listDocuments()); active = App.ActiveDocument.Name if App.ActiveDocument else None source_before = _source_hashes(); checks = []; report_path = output / "parameter-preflight-report.json" def check(name, callback): report_path.write_text(json.dumps({"checks": checks, "running_case": name}, ensure_ascii=False, indent=2), encoding="utf-8") try: checks.append({"name": name, "passed": True, "evidence": callback()}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) finally: for name_ in set(App.listDocuments()) - initial_docs: App.closeDocument(name_) if active and active in App.listDocuments(): App.setActiveDocument(active) def invalid_persistent_support(): doc = _support_fixture("ParameterSupportReject") evidence = [] for angle in (90, 270): evidence.append(_reject(doc, lambda angle=angle: parameters.apply_document_parameters(doc, [{ "key": "Revolution:Angle", "value": str(angle) + " deg", "expression": ""}]))) doc.redo(); doc.recompute() _near(doc.OccurrenceB.LinkPlacement.Base.x, 110); _near(doc.Revolution.Angle, 180) _near(doc.Pocket.Shape.Volume, 578 * math.pi) assert not _invalid(doc) and doc.UndoCount == 1 and doc.RedoCount == 0 return {"rejections": evidence, "original_redo_executed": True, "native_redo_x_mm": doc.OccurrenceB.LinkPlacement.Base.x} def valid_reference_edit(): doc = _support_fixture("ParameterSupportValid") structure, original_shapes = _structure(doc), _physical_shapes(doc) parameters.apply_document_parameters(doc, [{"key": "Pocket:Length", "value": "4 mm", "expression": ""}]) _near(doc.Pocket.Shape.Volume, 576 * math.pi) assert not _invalid(doc) and doc.UndoCount == 1 and doc.RedoCount == 0 expected_structure, expected_shapes = _structure(doc), _physical_shapes(doc) doc.undo(); doc.recompute(); assert _structure(doc) == structure and not _invalid(doc) and doc.RedoCount == 1 undo_shapes = _compare_shapes(original_shapes, _physical_shapes(doc)) doc.redo(); doc.recompute(); assert _structure(doc) == expected_structure and not _invalid(doc) redo_shapes = _compare_shapes(expected_shapes, _physical_shapes(doc)) doc.saveAs(str(output / "valid-reference-retaining.FCStd")) (output / "valid-reference-structure.json").write_text(json.dumps(_structure(doc), ensure_ascii=False, indent=2), encoding="utf-8") for key, shape in _physical_shapes(doc).items(): shape.exportBrep(str(output / ("valid-reference-" + key + ".brep"))) return {"volume_mm3": doc.Pocket.Shape.Volume, "undo_native_shape_comparison": undo_shapes, "redo_native_shape_comparison": redo_shapes} def valid270_control(): doc = App.newDocument("ParameterNoFaceSupport270"); doc.UndoMode = 1 body = doc.addObject("PartDesign::Body", "Body"); profile = _rectangle(body, 5, 12) feature = _revolution(doc, body, profile, "Revolution", 360) doc.clearUndos(); doc.openTransaction("Native baseline angle") feature.Angle = 180; doc.recompute(); doc.commitTransaction(); doc.undo(); doc.recompute() assert doc.UndoCount == 0 and doc.RedoCount == 1 parameters.apply_document_parameters(doc, [{"key": "Revolution:Angle", "value": "270 deg", "expression": ""}]) _near(feature.Angle, 270); _near(feature.Shape.Volume, 225 * math.pi) assert not _invalid(doc) and doc.UndoCount == 1 and doc.RedoCount == 0 doc.saveAs(str(output / "valid-270.FCStd")) return {"native_type": feature.TypeId, "actual_volume_mm3": feature.Shape.Volume, "scope": "No downstream face attachment; 270 is not generally invalid"} def ordinary_sheet_input(): doc = App.newDocument("ParameterRealUserSheet"); doc.UndoMode = 1 body = model.create_box(2, 3, 4, doc); sheet = doc.addObject("Spreadsheet::SheetPython", "UserInput") sheet.set("A1", "4 mm"); sheet.setAlias("A1", "Height") body.QIParameters.setExpression("QIHeight", "UserInput.Height"); doc.recompute() assert "QIBOMType" not in sheet.PropertiesList; _cad_table(body, None, 4) original = {"A1": sheet.getContents("A1"), "alias": sheet.getAlias("A1")}; assert original["alias"] == "Height" doc.clearUndos(); key = _height_row(body)["key"] parameters.apply_document_parameters(doc, [{"key": key, "expression": "2 * UserInput.Height"}]) updated = _cad_table(body, None, 8) assert sheet.getContents("A1") == original["A1"] and sheet.getAlias("A1") == original["alias"] assert doc.UndoCount == 1 and doc.RedoCount == 0 rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": key, "expression": "UserInput.MissingAlias"}])) doc.saveAs(str(output / "real-user-sheet.FCStd")) (output / "real-user-sheet-contents.json").write_text(json.dumps(original), encoding="utf-8") return {"actual_input_type": sheet.TypeId, "native_input_contents_preserved": original, "cad": updated, "rejection": rejection} def parameters_after_drawing_bom(): doc, body, page = _bom_fixture("ParameterDrawingBOM") model.set_dimensions(body, {"QIHeight": 5}); doc.undo(); doc.recompute(); model.prepare_drawing(page) initial = _cad_table(body, page, 4); assert doc.RedoCount == 1 before_undo = doc.UndoCount parameters.apply_document_parameters(doc, [{"key": _height_row(body)["key"], "value": 8}]) immediate = _cad_table(body, page, 8) # before extra caller recompute or prepare assert doc.UndoCount == before_undo + 1 and doc.RedoCount == 0 exported = _direct_dxf(page, output, "integrated-height8-direct", 8) doc.undo(); doc.recompute(); undo = _cad_table(body, page, 4) undo_export = _direct_dxf(page, output, "integrated-height4-undo-direct", 4) doc.redo(); doc.recompute(); redo = _cad_table(body, page, 8) redo_export = _direct_dxf(page, output, "integrated-height8-redo-direct", 8) doc.saveAs(str(output / "parameter-drawing-bom.FCStd")) return {"initial": initial, "immediate": immediate, "one_native_undo_step": True, "undo": undo, "redo": redo, "actual_direct_DXF": [exported, undo_export, redo_export], "GUI_parameter_Apply_unproven_here": True} def own_output_as_model_input(): doc, first, page = _bom_fixture("ParameterOutputInputGuard") sheet = drawing_bom.table_for_page(page).Source second = model.create_box(2, 3, 1, doc); second.QIParameters.setExpression("QIHeight", sheet.Name + ".D2") doc.recompute(); _near(second.Shape.Volume, 6); assert not _invalid(doc) assert all(second not in view.Source for view in sheet.QIBOMGeometryViews) and second.QIParameters in sheet.InList marker = doc.addObject("App::FeaturePython", "NativeHistoryMarker"); marker.addProperty("App::PropertyString", "Text") marker.Text = "baseline"; doc.recompute(); doc.clearUndos(); doc.openTransaction("Native guard history branch") marker.Text = "redo-value"; doc.commitTransaction(); doc.undo(); doc.recompute(); App.setActiveDocument(doc.Name) assert doc.UndoCount == 0 and doc.RedoCount == 1 rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": _height_row(first)["key"], "value": 8}]), "вход") doc.redo(); doc.recompute(); assert marker.Text == "redo-value" and not _invalid(doc) _near(first.Shape.Volume, 24); _near(second.Shape.Volume, 6) return {"native_input_expression": sheet.Name + ".D2", "valid_native_noncyclic_fixture": True, "rejection": rejection, "original_redo_executed": True} def native_output_expressions(): doc, body, page = _bom_fixture("ParameterNativeOutputExpressions") parameters.apply_document_parameters(doc, additions=[{"name": "ViewScale", "kind": "scalar", "value": 1.1}]) front = doc.FrontView; front.ScaleType = "Custom" front.setExpression("Scale", "QIUserParameters.ViewScale") sheet = drawing_bom.table_for_page(page).Source sheet.set("H1", "=QIUserParameters.ViewScale * 2") doc.recompute(); model.prepare_drawing(page) original_note = sheet.getContents("H1") _assert_float_cell(sheet, 2.2, original_note) key = "QIUserParameters:ViewScale" parameters.apply_document_parameters(doc, [{"key": key, "value": 1.2}]) doc.undo() first_immediate_undo = _assert_float_cell(sheet, 2.2, original_note) doc.recompute(); model.prepare_drawing(page) _near(front.Scale, 1.1); _near(sheet.H1, 2.2); assert doc.RedoCount == 1 both_rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=[key])) doc.redo(); doc.recompute(); model.prepare_drawing(page) _near(front.Scale, 1.2); _near(sheet.H1, 2.4) undo_count = doc.UndoCount parameters.apply_document_parameters(doc, [{"key": key, "value": 1.3}]) _near(front.Scale, 1.3); _near(sheet.H1, 2.6) assert doc.UndoCount == undo_count + 1 and sheet.getContents("H1") == original_note assert dict(front.ExpressionEngine)["Scale"] == "QIUserParameters.ViewScale" _near(body.Shape.Volume, 24); assert body.Shape.isValid() and not _invalid(doc) doc.undo() immediate_undo = _assert_float_cell(sheet, 2.4, original_note) doc.recompute(); model.prepare_drawing(page) _near(front.Scale, 1.2); _near(sheet.H1, 2.4) doc.redo() immediate_redo = _assert_float_cell(sheet, 2.6, original_note) doc.recompute(); model.prepare_drawing(page) _near(front.Scale, 1.3); _near(sheet.H1, 2.6) drawing_bom.validate_table(page) # The native view formula must not conceal a missing user-cell check. front.setExpression("Scale", None); doc.recompute(); model.prepare_drawing(page) parameters.apply_document_parameters(doc, [{"key": key, "value": 1.4}]) doc.undo() isolated_immediate_undo = _assert_float_cell(sheet, 2.6, original_note) doc.recompute(); model.prepare_drawing(page) assert doc.RedoCount == 1 and not list(front.ExpressionEngine) cell_only_rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=[key])) doc.redo(); doc.recompute(); model.prepare_drawing(page) _near(sheet.H1, 2.8); _near(body.Shape.Volume, 24) assert sheet.getContents("H1") == original_note and not _invalid(doc) return {"native_front_scale_expression_and_BOM_note_delete_rejection": both_rejection, "original_scale_redo_executed": True, "valid_change_has_one_native_undo": True, "first_undo_before_recompute": first_immediate_undo, "undo_before_recompute": immediate_undo, "redo_before_recompute": immediate_redo, "isolated_undo_before_recompute": isolated_immediate_undo, "BOM_user_note_formula_preserved": original_note, "isolated_BOM_user_note_delete_rejection": cell_only_rejection, "isolated_BOM_user_note_original_redo_executed": True} def spreadsheet_cell_type_history_guard(): results = [] for index, kind in enumerate(("Spreadsheet::Sheet", "Spreadsheet::SheetPython", "OwnBOM")): doc = App.newDocument("ParameterCellTypeGuard" + str(index)); doc.UndoMode = 1 parameters.apply_document_parameters(doc, additions=[{"name": "Scale", "kind": "scalar", "value": 1}]) source = doc.QIUserParameters if kind == "OwnBOM": body = model.create_box(2, 3, 4, doc) page = model.create_drawing(doc) sheet = drawing_bom.create_bom_table(page).Source else: sheet = doc.addObject(kind, "UserInput") body = doc.addObject("Part::Box", "Box"); body.Length = 2; body.Width = 3 page = None sheet.set("H1", "=QIUserParameters.Scale * 2") if kind != "OwnBOM": body.setExpression("Height", "UserInput.H1 * 1 mm") doc.recompute() if page: model.prepare_drawing(page) formula = sheet.getContents("H1") initial = _cell(sheet) assert initial["present"] and initial["type"] == "App::PropertyInteger" and initial["value"] == 2, initial assert not _invalid(doc) if kind != "OwnBOM": assert "QIBOMType" not in sheet.PropertiesList _near(body.Shape.Volume, 12) doc.openTransaction("Independent executable Redo for cell type guard") body.Placement.Base.x = 11 doc.recompute(); doc.commitTransaction(); doc.undo(); doc.recompute() if page: model.prepare_drawing(page) assert doc.RedoCount == 1 rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, [{ "key": "QIUserParameters:Scale", "value": 1.1}]), "тип вычисленной ячейки") assert _cell(sheet) == initial, _cell(sheet) doc.redo(); _near(body.Placement.Base.x, 11); doc.recompute() if page: model.prepare_drawing(page) # Establish the independent valid Float baseline outside any live # transaction, preserving the formula and native numeric semantics. source.Scale = 1.1; doc.recompute() if page: model.prepare_drawing(page) _assert_float_cell(sheet, 2.2, formula) undo_before = doc.UndoCount parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Scale", "value": 1.2}]) assert doc.UndoCount == undo_before + 1 _assert_float_cell(sheet, 2.4, formula) if kind != "OwnBOM": _near(body.Shape.Volume, 14.4) doc.undo() immediate_undo = _assert_float_cell(sheet, 2.2, formula) doc.recompute() if page: model.prepare_drawing(page) doc.redo() immediate_redo = _assert_float_cell(sheet, 2.4, formula) doc.recompute() if page: model.prepare_drawing(page) inverse_rejection = _reject(doc, lambda: parameters.apply_document_parameters(doc, [{ "key": "QIUserParameters:Scale", "value": 1}]), "тип вычисленной ячейки") _assert_float_cell(sheet, 2.4, formula) assert not _invalid(doc) results.append({"kind": kind, "genuine_CAD_input": kind != "OwnBOM", "integer_to_float_rejection": rejection, "original_redo_x_mm": body.Placement.Base.x, "stable_float_change_one_undo": True, "undo_before_recompute": immediate_undo, "redo_before_recompute": immediate_redo, "float_to_integer_rejection": inverse_rejection}) if page: drawing_bom.validate_table(page) App.closeDocument(doc.Name) return results try: for name, callback in (("Persistent topology rejects before live and retains executable Redo", invalid_persistent_support), ("Valid reference retaining Pocket edit native shapes Undo Redo", valid_reference_edit), ("Valid native270 without face support", valid270_control), ("Ordinary user SheetPython remains genuine model input", ordinary_sheet_input), ("Parameters after real drawing BOM source cells mass and direct DXF", parameters_after_drawing_bom), ("Own output used as native CAD input is guarded before live", own_output_as_model_input), ("Native drawing scale and ordinary BOM cell expressions validate before live", native_output_expressions), ("Native Spreadsheet and own BOM cell type changes preserve executable history", spreadsheet_cell_type_history_guard)): check(name, callback) check("Native canonical formula no-op preserves executable history; distinct equal-value formulas remain edits", _canonical_expression_history) check("Fresh native process FCStd history inputs outputs and real reedit", lambda: _reopen_child(output)) finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) if active and active in App.listDocuments(): App.setActiveDocument(active) source_stable = source_before == _source_hashes() report = {"passed": all(row["passed"] for row in checks) and source_stable, "count": len(checks), "checks": checks, "gui": bool(App.GuiUp), "source_sha256": source_before, "source_stable": source_stable, "parameter_module": parameters.__file__, "parameter_module_sha256": hashlib.sha256(Path(parameters.__file__).read_bytes()).hexdigest()} report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report