715 lines
42 KiB
Python
715 lines
42 KiB
Python
"""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
|