Files
Qinventor/tests/kernel_suite.py
T

722 lines
41 KiB
Python

"""Real FreeCAD/Open CASCADE integration checks (no mocked geometry).
Run inside portable FreeCAD Python, or import run(output_dir) from a GUI macro.
The report contains explicit pass/fail/skip flags and persistent evidence files.
"""
import json
import math
from pathlib import Path
import sys
import traceback
import FreeCAD as App
import Part
# FreeCAD itself may prepend workbench paths during import. Prefer repository
# source for validation when present; the packaged workbench is the fallback.
_SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor"
if _SOURCE.is_dir():
sys.path.insert(0, str(_SOURCE))
import qi_model as model
def _assert(condition, message):
if not condition:
raise AssertionError(message)
def _near(actual, expected, relative=1e-8, absolute=1e-6):
_assert(math.isclose(float(actual), float(expected), rel_tol=relative, abs_tol=absolute),
"Expected %.12g, got %.12g" % (expected, actual))
def _sketches(body):
return [o for o in body.Group if o.isDerivedFrom("Sketcher::SketchObject")]
def _valid(body):
_assert(body.Shape.isValid(), "Invalid BRep")
_assert(len(body.Shape.Solids) == 1, "Expected a single solid")
_assert(body.Shape.Volume > 0, "Empty solid")
_assert(body.getStatusString() == "Valid", "Body status: " + body.getStatusString())
def _reject(fn):
try:
fn()
except (ValueError, RuntimeError):
return
raise AssertionError("Invalid input was accepted")
def _flat_exports_world_geometry_and_history(output_dir):
"""Analytical cold world geometry, immutable sources and atomic flat export."""
import hashlib
import io
import zipfile
import Mesh
def state(doc):
objects=[]
for obj in doc.Objects:
raw=bytes(obj.dumpContent())
if zipfile.is_zipfile(io.BytesIO(raw)):
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
payload={n:hashlib.sha256(archive.read(n)).hexdigest() for n in sorted(archive.namelist())}
else:payload={'raw':hashlib.sha256(raw).hexdigest()}
objects.append({'name':obj.Name,'type':obj.TypeId,'payload':payload,
'expressions':list(obj.ExpressionEngine),'state':list(obj.State)})
return {'objects':objects,'undo':doc.UndoCount,'redo':doc.RedoCount,
'active_transaction':App.getActiveTransaction(),'active_doc':App.ActiveDocument.Name if App.ActiveDocument else None,
'documents':sorted(App.listDocuments()),'filename':doc.FileName}
def bounds(box):return [box.XMin,box.YMin,box.ZMin,box.XMax,box.YMax,box.ZMax]
def geometry(shape):
solids=list(shape.Solids); volume=sum(s.Volume for s in solids)
com=sum((s.CenterOfMass*s.Volume for s in solids),App.Vector())/volume if volume else App.Vector()
return {'type':shape.ShapeType,'valid':shape.isValid(),'solids':len(solids),'shells':len(shape.Shells),
'faces':len(shape.Faces),'volume_mm3':volume,'native_volume_mm3':shape.Volume,
'com_mm':list(com),'bounds_mm':bounds(shape.BoundBox)}
def comparison(expected,actual,tolerance=1e-6):
wanted,read=geometry(expected),geometry(actual)
missing=expected.cut(actual); excess=actual.cut(expected)
forward=0.0 if missing.isNull() else missing.Volume
reverse=0.0 if excess.isNull() else excess.Volume
return {'expected':wanted,'actual':read,'missing_volume_mm3':forward,'excess_volume_mm3':reverse,
'volume_delta_mm3':abs(read['volume_mm3']-wanted['volume_mm3']),
'com_distance_mm':math.dist(wanted['com_mm'],read['com_mm']),
'max_bound_delta_mm':max(abs(a-b) for a,b in zip(wanted['bounds_mm'],read['bounds_mm'])),
'matches':bool(read['valid'] and read['solids']==wanted['solids'] and forward<tolerance and reverse<tolerance
and abs(read['volume_mm3']-wanted['volume_mm3'])<tolerance
and math.dist(wanted['com_mm'],read['com_mm'])<tolerance
and max(abs(a-b) for a,b in zip(wanted['bounds_mm'],read['bounds_mm']))<tolerance)}
def box_shape(dimensions,base,degrees):
shape=Part.makeBox(*dimensions)
shape.Placement=App.Placement(App.Vector(*base),App.Rotation(App.Vector(0,0,1),degrees))
return shape
def body(doc,name,dimensions):
result=doc.addObject('PartDesign::Body',name)
box=result.newObject('PartDesign::AdditiveBox',name+'Box')
box.Length,box.Width,box.Height=dimensions
doc.recompute();assert result.Tip is box and result.Shape.isValid() and len(result.Shape.Solids)==1
return result
def fixture(kind,name):
doc=App.newDocument(name);doc.UndoMode=1
if kind=='placed_body':
selected=body(doc,'Body',(2,3,4))
parent=doc.addObject('App::Part','Definitions');parent.addObject(selected)
parent.Placement=App.Placement(App.Vector(50,60,70),App.Rotation(App.Vector(0,0,1),90))
selected.Placement=App.Placement(App.Vector(10,20,30),App.Rotation(App.Vector(0,0,1),90))
# Independent arithmetic: Rz90*(10,20,30)+(50,60,70)=(30,70,100), rotation180.
expected=box_shape((2,3,4),(30,70,100),180)
doc.recompute()
placements={'parent':[50,60,70,90],'body':[10,20,30,90],'independent_world':[30,70,100,180]}
elif kind in ('array','masked_array_virtual','masked_array_expanded','hidden_element','scaled_mirror_subassembly'):
definitions=doc.addObject('App::DocumentObjectGroup','Definitions')
source=doc.addObject('Part::Box','Definition');source.Length=2;source.Width=3;source.Height=4;definitions.addObject(source)
selected=doc.addObject('Assembly::AssemblyObject','Assembly')
selected.Placement=App.Placement(App.Vector(100,200,300),App.Rotation(App.Vector(0,0,1),90))
if kind in ('array','masked_array_virtual','masked_array_expanded','hidden_element'):
array=doc.addObject('App::Link','Array');array.setLink(source);array.LinkTransform=True;selected.addObject(array)
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(s,s,s) for s in (1,2,3)]
doc.recompute()
if kind in ('masked_array_expanded','hidden_element'):
array.ShowElement=True;doc.recompute()
expected=Part.makeCompound([box_shape((2*s,3*s,4*s),(100,200+10*i,300),90) for i,s in enumerate((1,2,3))])
placements={'root':[100,200,300,90],'array_local_x':[0,10,20],'array_scales':[1,2,3],
'independent_occurrences':3,'all_elements_visible':True}
if kind!='array':
doc.recompute();array.setElementVisible('1',False);doc.recompute()
assert [array.isElementVisible(str(i)) for i in range(3)]==[1,0,1]
if kind=='hidden_element':
assert len(array.ElementList)==3
selected=array.ElementList[1]
expected=box_shape((4,6,8),(100,210,300),90)
placements.update(explicitly_selected_hidden_element=selected.Name,independent_occurrences=1)
else:
expected=Part.makeCompound([box_shape((2,3,4),(100,200,300),90),box_shape((6,9,12),(100,220,300),90)])
placements.update(independent_occurrences=2)
placements.update(all_elements_visible=False,native_element_visibility=[1,0,1])
else:
sub=doc.addObject('Assembly::AssemblyObject','SubAssembly');definitions.addObject(sub)
for name_,x,angle in (('FirstChild',10,0),('SecondChild',30,90)):
child=doc.addObject('App::Link',name_);child.setLink(source);child.LinkTransform=True
child.LinkPlacement=App.Placement(App.Vector(x,0,0),App.Rotation(App.Vector(0,0,1),angle));sub.addObject(child)
for name_,x,scale in (('ScaledSub',50,2),('MirrorSub',200,-1)):
link=doc.addObject('App::Link',name_);link.setLink(sub);link.LinkTransform=True
link.LinkPlacement=App.Placement(App.Vector(x,0,0),App.Rotation());link.Scale=scale;selected.addObject(link)
expected=Part.makeCompound([box_shape((4,6,8),(100,270,300),90),box_shape((4,6,8),(100,310,300),180),
box_shape((3,2,4),(100,388,296),0),box_shape((2,3,4),(100,370,296),0)])
placements={'root':[100,200,300,90],'source_child_x':[10,30],'source_child_rotations_z':[0,90],
'linked_subassembly_local_x':[50,200],'linked_subassembly_scales':[2,-1],
'independent_occurrences':4,'independent_volume_mm3':432}
doc.recompute()
else:
definitions=doc.addObject('App::DocumentObjectGroup','Definitions')
first=body(doc,'FirstDefinition',(2,3,4));second=body(doc,'SecondDefinition',(5,6,7))
definitions.addObject(first);definitions.addObject(second)
selected=doc.addObject('Assembly::AssemblyObject','Assembly')
selected.Placement=App.Placement(App.Vector(100,200,300),App.Rotation(App.Vector(0,0,1),90))
for source,name_,base,rotation in ((first,'OccurrenceA',(10,20,30),90),(second,'OccurrenceB',(40,50,60),180)):
link=doc.addObject('App::Link',name_);link.setLink(source);link.LinkTransform=True
link.LinkPlacement=App.Placement(App.Vector(*base),App.Rotation(App.Vector(0,0,1),rotation))
selected.addObject(link)
# Exactly two independent occurrences; source definitions and container are not counted again.
expected=Part.makeCompound([box_shape((2,3,4),(80,210,330),180),box_shape((5,6,7),(50,240,360),270)])
doc.recompute()
placements={'root':[100,200,300,90],'A_local':[10,20,30,90],'B_local':[40,50,60,180],
'A_world':[80,210,330,180],'B_world':[50,240,360,270]}
marker=doc.addObject('App::FeaturePython','HistoryMarker');marker.addProperty('App::PropertyString','Text');marker.Text='before'
doc.recompute();doc.clearUndos();doc.openTransaction('Independent executable export Redo')
marker.Text='after';doc.commitTransaction();doc.undo();doc.recompute()
sentinel=App.newDocument(name+'Sentinel');App.setActiveDocument(sentinel.Name)
assert doc.UndoCount==0 and doc.RedoCount==1
return doc,selected,expected,marker,placements
def fault_control(model,out,suffix):
doc,selected,expected,marker,placements=fixture('placed_assembly','WriterFault'+suffix.replace('.',''))
destination=out/('protected-old'+suffix);old=b'owned previous complete exchange artifact';destination.write_bytes(old)
before=state(doc);files={p.name for p in out.iterdir()};injected='Owned post-native-writer failure'
native_part=model.Part
class CompoundWriter:
def __init__(self,shape):self.shape=shape
def exportBrep(self,path):self.shape.exportBrep(path);raise RuntimeError(injected)
def exportIges(self,path):self.shape.exportIges(path);raise RuntimeError(injected)
def __getattr__(self,name):return getattr(self.shape,name)
class PartProxy:
def makeCompound(self,shapes):return CompoundWriter(native_part.makeCompound(shapes))
def __getattr__(self,name):return getattr(native_part,name)
mesh_part=None;original_mesher=None
if suffix=='.stl':
import MeshPart
mesh_part=MeshPart;original_mesher=MeshPart.meshFromShape
class MeshWriter:
def __init__(self,mesh):self.mesh=mesh
def write(self,path):self.mesh.write(path);raise RuntimeError(injected)
MeshPart.meshFromShape=lambda **kwargs:MeshWriter(original_mesher(**kwargs))
else:model.Part=PartProxy()
try:
try:model.export_model(doc,destination,[selected])
except RuntimeError as exc:
assert str(exc)==injected,str(exc)
else:raise AssertionError('Injected post-writer failure was ignored')
finally:
model.Part=native_part
if mesh_part is not None:mesh_part.meshFromShape=original_mesher
after=state(doc)
assert before==after,'Post-writer error changed source payload/history/context'
assert destination.read_bytes()==old,'Post-writer error replaced previous destination'
assert {p.name for p in out.iterdir()}==files,'Post-writer error leaked or removed files'
doc.redo();doc.recompute();assert marker.Text=='after'
return {'source_before':before,'source_after':after,'original_exact_payload_history_active_doc':True,
'original_redo_executed':True,'old_destination_exact':True,'temporary_cleanup_exact':True,
'native_writer_completed_before_injected_failure':True,'before_first_world_query':True}
def empty_mask_control(model,out,suffix):
doc,selected,expected,marker,placements=fixture('array','EmptyMask'+suffix.replace('.',''))
for index in range(3):doc.Array.setElementVisible(str(index),False)
doc.recompute();before=state(doc)
destination=out/('protected-empty-mask'+suffix);old=b'owned previous nonempty model export';destination.write_bytes(old)
files={p.name for p in out.iterdir()}
try:model.export_model(doc,destination,[selected])
except ValueError as exc:error=str(exc)
else:raise AssertionError('All-hidden array was exported as successful empty geometry')
assert state(doc)==before,'Empty-mask rejection changed source payload/history/context'
assert destination.read_bytes()==old and {p.name for p in out.iterdir()}==files
doc.redo();doc.recompute();assert marker.Text=='after'
return {'original_exact_payload_history_active_doc':True,'original_redo_executed':True,
'old_destination_exact':True,'temporary_cleanup_exact':True,'empty_geometry_rejected':error,
'before_first_world_query':True}
output=Path(output_dir)/"flat-world-regression";output.mkdir(parents=True,exist_ok=True)
original_docs=set(App.listDocuments());active=App.ActiveDocument.Name if App.ActiveDocument else None
results=[]
def close_created():
for name in set(App.listDocuments())-original_docs:App.closeDocument(name)
if active and active in App.listDocuments():App.setActiveDocument(active)
elif not active:App.setActiveDocument("")
try:
for kind in ("placed_assembly","placed_body","array","scaled_mirror_subassembly","masked_array_virtual","masked_array_expanded","hidden_element"):
for selection in (("explicit",) if kind=="hidden_element" else ("default","explicit")):
for suffix in (".brep",".iges",".stl"):
name=kind+"-"+selection+suffix.replace(".","-")
try:
doc,selected,expected,marker,placements=fixture(kind,"FlatRegression"+name.replace("-",""))
before=state(doc)
assert state(doc)==before,"Read-only native payload snapshot is unstable"
destination=output/(name+suffix)
model.export_model(doc,destination,objects=None if selection=="default" else [selected])
assert state(doc)==before,"Export changed source payload/history/context: "+name
row={"case":name,"known_placements":placements,"exact_snapshot_before_first_world_query":True}
if suffix==".stl":
mesh=Mesh.Mesh();mesh.read(str(destination));wanted=geometry(expected)
for a,b in zip(wanted["bounds_mm"],bounds(mesh.BoundBox)):_near(a,b,absolute=1e-5)
_near(abs(mesh.Volume),wanted["volume_mm3"],absolute=1e-5)
assert mesh.isSolid() and mesh.CountFacets>=12*wanted["solids"]
row.update(stl_facets=mesh.CountFacets,volume_mm3=mesh.Volume,bounds_mm=bounds(mesh.BoundBox))
else:
actual=Part.read(str(destination));row["raw_readback"]=geometry(actual)
if suffix==".iges" and not actual.Solids:
actual.sewShape();shells=list(actual.Shells)
assert shells and all(shell.isClosed() for shell in shells),"IGES surfaces do not form closed shells"
actual=Part.makeCompound([Part.makeSolid(shell) for shell in shells])
row["iges_topology_readback"]="native sewShape of raw imported surfaces and closed-shell makeSolid"
row["comparison"]=comparison(expected,actual,1e-5)
assert row["comparison"]["matches"],row
assert state(doc)==before,"Readback changed source payload/history/context: "+name
doc.redo();doc.recompute();assert marker.Text=="after","Original Redo did not execute: "+name
row["original_redo_executed"]=True;results.append(row)
finally:close_created()
for suffix in (".brep",".iges",".stl"):
try:
fault=fault_control(model,output,suffix)
results.append({"case":"post-native-writer-fault"+suffix,
**{key:value for key,value in fault.items() if key not in ("source_before","source_after")}})
finally:close_created()
for suffix in (".brep",".iges",".stl"):
try:results.append({"case":"all-hidden-array-rejection"+suffix,**empty_mask_control(model,output,suffix)})
finally:close_created()
finally:close_created()
assert len(results)==45
return {"checks":len(results),"analytical_geometry_and_history":results}
def run(output_dir):
out = Path(output_dir).absolute()
out.mkdir(parents=True, exist_ok=True)
initial_docs = set(App.listDocuments())
active_name = App.ActiveDocument.Name if App.ActiveDocument else None
report = {"status": "running", "freecad_version": App.Version(), "gui": bool(App.GuiUp),
"source": model.__file__, "passed": 0, "failed": 0, "skipped": 0,
"checks": [], "artifacts": []}
def check(name, fn):
try:
detail = fn()
report["checks"].append({"name": name, "passed": True, "status": "passed", "detail": detail or {}})
report["passed"] += 1
except Exception as exc:
report["checks"].append({"name": name, "passed": False, "status": "failed",
"error": str(exc), "traceback": traceback.format_exc()})
report["failed"] += 1
# The report survives an unexpected later runtime/GUI failure.
(out / "kernel-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
def artifact(path):
path = Path(path)
_assert(path.is_file() and path.stat().st_size > 0, "Missing artifact: " + str(path))
report["artifacts"].append(str(path))
return path
def box_volume_constraints():
doc = model.new_part("Проверка бруска")
body = model.create_box(80, 40, 20, doc)
_valid(body)
_near(body.Shape.Volume, 64000)
for actual, expected in zip(model.mass_properties(body)["dimensions_mm"], (80, 40, 20)):
_near(actual, expected)
sketch = _sketches(body)[0]
_assert(sketch.FullyConstrained and sketch.solve() == 0, "Rectangle not fully constrained")
_assert({c.Name for c in sketch.Constraints} >= {"Длина", "Ширина"}, "Missing localized named driving constraints")
_assert(body.Tip.TypeId == "PartDesign::Pad", "Box is not a native Pad")
return {"volume_mm3": body.Shape.Volume, "fully_constrained": True, "feature": body.Tip.TypeId}
def box_edit_undo_redo():
doc = model.new_part("Изменение размера")
body = model.create_box(20, 30, 10, doc)
model.set_dimensions(body, {"QILength": 25, "QIWidth": 40, "QIHeight": 12})
_near(body.Shape.Volume, 12000)
doc.undo()
doc.recompute()
body = doc.getObject(body.Name)
_near(body.Shape.Volume, 6000)
doc.redo()
doc.recompute()
_near(body.Shape.Volume, 12000)
# The native VarSet remains independently editable in the property editor.
body.QIParameters.QIHeight = 15
doc.recompute()
_near(body.Shape.Volume, 15000)
_valid(body)
return {"volume_mm3": body.Shape.Volume, "undo_redo": True, "native_parameters": body.QIParameters.TypeId}
def creation_undo_redo():
doc = model.new_part("История построения")
body = model.create_cylinder(8, 25, doc)
name = body.Name
doc.undo()
doc.recompute()
_assert(doc.getObject(name) is None, "Builder did not commit as a single undo operation")
doc.redo()
doc.recompute()
_near(doc.getObject(name).Shape.Volume, math.pi * 8 ** 2 * 25)
return {"single_creation_undo": True}
def cylinder_material():
doc = model.new_part("Материал цилиндра")
body = model.create_cylinder(12, 60, doc)
_valid(body)
_near(body.Shape.Volume, math.pi * 12 ** 2 * 60)
_assert(_sketches(body)[0].FullyConstrained, "Circle not fully constrained")
model.set_material(body, "Алюминий")
props = model.mass_properties(body)
_near(props["mass_kg"], body.Shape.Volume * 1e-9 * 2700)
model.set_dimension(body, "QIRadius", 15)
_near(body.Shape.Volume, math.pi * 15 ** 2 * 60)
return {"material": props["material"], "mass_kg": props["mass_kg"]}
def plate_parametric_holes():
doc = model.new_part("Проверка отверстий")
body = model.create_plate_with_holes(100, 50, 8, 10, 60, doc)
_near(body.Shape.Volume, (100 * 50 - 2 * math.pi * 5 ** 2) * 8)
_assert(all(s.FullyConstrained and s.solve() == 0 for s in _sketches(body)), "Plate sketches have degrees of freedom")
model.set_dimensions(body, {"QILength": 120, "QIWidth": 60, "QIThickness": 12,
"QIHoleDiameter": 12, "QIHoleSpacing": 90})
_near(body.Shape.Volume, (120 * 60 - 2 * math.pi * 6 ** 2) * 12)
holes = next(s for s in _sketches(body) if s.Name.startswith("HoleSketch"))
for circle, x in zip(holes.Geometry, (15, 105)):
_near(circle.Center.x, x)
_near(circle.Center.y, 30)
_near(circle.Radius, 6)
_near(holes.Placement.Base.z, 12)
_valid(body)
return {"volume_mm3": body.Shape.Volume, "fully_constrained": True, "holes": 2}
def atomic_dimensions_and_rejection():
doc = model.new_part("Атомарность размеров")
body = model.create_plate_with_holes(100, 50, 8, 10, 60, doc)
model.set_dimensions(body, {"QIHoleSpacing": 120, "QILength": 160})
_near(body.QILength.Value, 160)
_near(body.QIHoleSpacing.Value, 120)
before = body.Shape.Volume
_reject(lambda: model.set_dimensions(body, {"QILength": 80, "QIWidth": 20}))
_near(body.QILength.Value, 160)
_near(body.QIWidth.Value, 50)
_near(body.Shape.Volume, before)
_reject(lambda: model.set_dimensions(body, {"QIThickness": 12, "unknown": 1}))
_near(body.QIThickness.Value, 8)
return {"rejected_without_partial_changes": True}
def invalid_dimension_inputs():
doc = model.new_part("Проверка входных данных")
before = len(doc.Objects)
for invalid in (-1, 0, float("nan"), float("inf"), "abc", 1e20):
_reject(lambda v=invalid: model.create_box(v, 10, 10, doc))
_reject(lambda v=invalid: model.create_cylinder(v, 10, doc))
_reject(lambda: model.create_plate_with_holes(40, 20, 5, 15, 30, doc))
_reject(lambda: model.create_sketch_profile("rectangle", 20, 10, "BAD", doc))
_assert(len(doc.Objects) == before, "Invalid inputs modified document")
return {"invalid_cases": 14, "document_unchanged": True}
def free_sketch_dimensions():
doc = model.new_part("Именованные размеры эскиза")
sketch = model.create_sketch_profile("rectangle", 35, 20, doc=doc)
model.set_dimensions(sketch, {"Length": 50, "Width": 25})
_assert(sketch.FullyConstrained, "Dimensions caused underconstraint")
feature = model.pad(sketch, 10)
_near(feature.Shape.Volume, 12500)
circle = model.create_sketch_profile("circle", 7, 7, doc=doc)
model.set_dimension(circle, "Radius", 9)
feature2 = model.pad(circle, 15)
_near(feature2.Shape.Volume, math.pi * 9 ** 2 * 15)
return {"rectangle_driving_dimensions": [r["name"] for r in model.get_dimensions(sketch)], "circle_radius": 9}
def all_origin_planes():
doc = model.new_part("Плоскости эскиза")
expected_boxes = {"XY": (30, 20, 5), "XZ": (30, 5, 20), "YZ": (5, 30, 20)}
for plane, expected in expected_boxes.items():
sketch = model.create_sketch_profile("rectangle", 30, 20, plane, doc)
feature = model.pad(sketch, 5)
_near(feature.Shape.Volume, 3000)
box = feature.Shape.BoundBox
for actual, target in zip((box.XLength, box.YLength, box.ZLength), expected):
_near(actual, target)
return {"planes": list(expected_boxes)}
def pocket_and_rollback():
doc = model.new_part("Карман и откат")
body = model.create_box(50, 40, 10, doc)
sketch = model.create_sketch_profile("circle", 5, 5, doc=doc, body=body)
model.position_sketch_on_top(sketch, body)
placement = sketch.Placement
placement.Base.x, placement.Base.y = 25, 20
sketch.Placement = placement
cut = model.pocket(sketch, 10)
_near(cut.Shape.Volume, 20000 - math.pi * 25 * 10)
bad = model.create_sketch_profile("circle", 2, 2, doc=doc, body=body)
model.position_sketch_on_top(bad, body)
placement = bad.Placement
placement.Base = App.Vector(500, 500, 10)
bad.Placement = placement
before_names = {o.Name for o in doc.Objects}
_reject(lambda: model.pocket(bad, 10))
_assert({o.Name for o in doc.Objects} == before_names, "Failed feature transaction did not roll back")
_near(cut.Shape.Volume, 20000 - math.pi * 25 * 10)
return {"real_subtractive_volume_mm3": cut.Shape.Volume, "failed_pocket_rolled_back": True}
def save_and_reopen():
doc = model.make_demo("bracket")
name = doc.Name
body = doc.getObject("Body")
volume = body.Shape.Volume
path = artifact(model.save_project(doc, out / "parametric-bracket.FCStd"))
App.closeDocument(name)
reopened = App.openDocument(str(path))
body = reopened.getObject("Body")
_near(body.Shape.Volume, volume)
_assert(all(s.FullyConstrained for s in _sketches(body)), "Saved constraints lost")
model.set_dimensions(body, {"QIWallHeight": 55, "QIThickness": 10})
_near(body.Shape.Volume, (100 * 50 - 2 * math.pi * 5 ** 2) * 10 + 50 * 55 * 10)
_valid(body)
return {"native_project": str(path), "editable_after_reopen": True}
def step_round_trip():
doc = model.make_demo("bracket")
body = doc.getObject("Body")
path = artifact(model.export_model(doc, out / "bracket.step"))
shape = Part.read(str(path))
_assert(shape.isValid() and len(shape.Solids) == 1, "STEP did not round-trip a solid")
_near(shape.Volume, body.Shape.Volume, relative=1e-6)
return {"path": str(path), "imported_volume_mm3": shape.Volume, "valid": True}
def solid_export_formats():
doc = model.new_part("Экспорт форматов")
body = model.create_box(25, 20, 10, doc)
paths = [artifact(model.export_model(doc, out / ("solid." + ext))) for ext in ("iges", "brep", "stl")]
shape = Part.read(str(out / "solid.brep"))
_near(shape.Volume, body.Shape.Volume)
import Mesh
mesh = Mesh.Mesh(str(out / "solid.stl"))
_assert(mesh.CountFacets >= 12, "STL has insufficient facets")
return {"formats": [p.suffix for p in paths], "stl_facets": mesh.CountFacets}
def export_rejects_non_solids():
doc = model.new_part("Проверка геометрии экспорта")
body = model.create_box(20, 10, 5, doc)
sketch = _sketches(body)[0]
empty = doc.addObject("Part::Feature", "Empty")
wire = doc.addObject("Part::Feature", "Wire")
wire.Shape = Part.makePolygon([App.Vector(0, 0, 0), App.Vector(10, 0, 0)])
mixed = doc.addObject("Part::Feature", "Mixed")
mixed.Shape = Part.makeCompound([body.Shape, wire.Shape])
doc.recompute()
rejected = []
for obj in (sketch, empty, wire, mixed):
for ext in ("step", "iges", "brep", "stl"):
path = out / ("rejected-" + obj.Name + "." + ext)
try:
model.export_model(doc, path, [obj])
except ValueError as exc:
_assert("твердого тела" in str(exc), "Missing Russian solid-geometry error")
else:
raise AssertionError("Non-solid selection was exported: " + obj.Name)
_assert(not path.exists(), "Rejected export created a misleading file")
rejected.append(obj.Name + "." + ext)
existing = out / "rejected-existing.step"
existing.write_bytes(b"existing project exchange file")
before = existing.read_bytes()
_reject(lambda: model.export_model(doc, existing, [sketch]))
_assert(existing.read_bytes() == before, "Rejected export overwrote an existing file")
return {"rejected_selections": rejected, "existing_file_preserved": True}
def export_rejects_cached_failed_feature():
doc = model.new_part("Ошибка перестроения перед экспортом")
body = model.create_box(20, 10, 5, doc)
pad = body.Tip
link = doc.addObject("App::Link", "ExportLink")
link.setLink(body)
doc.recompute()
volume = body.Shape.Volume
pad.setExpression("Length", None)
pad.Length = 0
doc.recompute()
_assert("Invalid" in pad.State, "Fixture did not trigger a real native error")
_assert(pad.getStatusString() != "Valid", "Fixture has no native error status")
_assert(pad.Shape.isValid() and body.Shape.isValid(), "Fixture did not retain the old valid solid")
_near(body.Shape.Volume, volume)
native_status = pad.getStatusString()
rejected = 0
for selection in (None, [body], [pad], [link]):
for ext in ("step", "iges", "brep", "stl"):
path = out / ("failed-rebuild-%d.%s" % (rejected, ext))
try:
model.export_model(doc, path, selection)
except ValueError as exc:
_assert("ошибку перестроения" in str(exc), "Missing Russian rebuild error")
else:
raise AssertionError("A stale solid was exported after a native rebuild failure")
_assert(not path.exists(), "Failed rebuild created a misleading export")
rejected += 1
# An unrelated erroneous object should not prevent exporting an explicitly
# chosen independent, valid Body or its native feature.
good = model.create_box(7, 6, 5, doc)
accepted = artifact(model.export_model(doc, out / "independent-good-body.step", [good]))
_near(Part.read(str(accepted)).Volume, 210)
pad.Length = 8
doc.recompute()
_assert("Invalid" not in pad.State, "Native Pad did not recover")
recovered = artifact(model.export_model(doc, out / "recovered-body.step", [body]))
_near(Part.read(str(recovered)).Volume, 1600)
return {"native_error": native_status, "cached_volume_mm3": volume,
"rejected_exports": rejected, "independent_and_recovered_exports": True}
def assembly_solver_links_bom():
doc = model.create_assembly_demo()
assembly = doc.getObject("Assembly")
_assert(assembly.TypeId == "Assembly::AssemblyObject" and assembly.Type == "Assembly", "Non-native assembly")
links = [o for o in assembly.Group if o.isDerivedFrom("App::Link")]
_assert(len(links) == 3, "Assembly requires three linked components")
_assert(assembly.isPartGrounded(links[0]), "Base not grounded")
types = {o.JointType for o in doc.Objects if hasattr(o, "JointType")}
_assert(types == {"Fixed", "Revolute"}, "Missing native mates")
fixed = links[1]
moved = fixed.LinkPlacement
moved.Base = App.Vector(45, 35, 5)
fixed.LinkPlacement = moved
status = assembly.solve(False)
doc.recompute()
_assert(status == 0, "Assembly solver failed")
_near(fixed.LinkPlacement.Base.x, 20)
_near(fixed.LinkPlacement.Base.y, 30)
_near(fixed.LinkPlacement.Base.z, 0)
rows = model.bom_rows(doc)
_assert(sorted(r["quantity"] for r in rows) == [1, 2], "BOM quantity grouping incorrect")
csv = artifact(model.export_bom(doc, out / "assembly-bom.csv"))
_assert("QI-002" in csv.read_text(encoding="utf-8-sig"), "BOM CSV missing part")
pin = links[1].getLinkedObject()
model.set_dimensions(pin, {"QIRadius": 6, "QIHeight": 40})
doc.recompute()
for link in links[1:]:
_near(link.Shape.Volume, math.pi * 6 ** 2 * 40)
artifact(model.save_project(doc, out / "native-assembly.FCStd"))
return {"solver_status": status, "joint_types": sorted(types), "components": 3, "bom_rows": rows}
def assembly_reopen_step():
path = out / "native-assembly.FCStd"
_assert(path.is_file(), "Assembly artifact absent")
reopened = App.openDocument(str(path))
assembly = reopened.getObject("Assembly")
_assert(assembly.solve(False) == 0, "Reopened joints failed to solve")
_assert(sorted(r["quantity"] for r in model.bom_rows(reopened)) == [1, 2], "BOM lost after reopen")
step = artifact(model.export_model(reopened, out / "assembly.step"))
shape = Part.read(str(step))
_assert(len(shape.Solids) == 3 and shape.isValid(), "STEP assembly lost components")
return {"reopened_joints": True, "step_solids": len(shape.Solids)}
def drawing_associativity():
doc = model.new_part("Чертеж детали")
body = model.create_box(80, 40, 20, doc)
page = model.create_drawing(doc)
model.prepare_drawing(page)
_assert(page.TypeId == "TechDraw::DrawPage" and len(page.Views) == 3, "Drawing projections absent")
_assert(all(v.Source == [body] for v in page.Views), "Drawing source links missing")
_assert(all(len(v.getVisibleEdges()) >= 4 for v in page.Views), "Native projections contain no visible edges")
_assert(page.Template.Width.Value > 200 and page.Template.Height.Value > 200, "A4 template dimensions invalid")
_assert([(v.X.Value, v.Y.Value) for v in page.Views] == [(80.0, 135.0), (80.0, 80.0), (205.0, 123.0)],
"Drawing insertion reset projection layout")
_near(page.Scale, 1)
_assert(page.Template.EditableTexts.get("Scale") == "1:1", "Unexpected standard scale label")
# Export immediately after rapid source edits. Waiting/preparing here
# would hide a DXF exporter that serializes the previous cached view.
model.set_dimensions(body, {"QILength": 90, "QIHeight": 35})
model.set_dimensions(body, {"QILength": 100, "QIHeight": 50})
dxf = artifact(model.export_drawing(page, out / "associative-drawing.dxf"))
dxf_lines = dxf.read_text(encoding="utf-8", errors="replace").splitlines()
section, record, front_lines = "", [], []
for raw_code, raw_value in zip(dxf_lines[::2], dxf_lines[1::2]):
code, value = int(raw_code), raw_value.strip()
if code == 0:
if (section == "ENTITIES" and record and record[0] == (0, "LINE")
and (8, "FrontView") in record):
front_lines.append(record)
record = [(code, value)]
if value == "ENDSEC":
section = ""
elif code == 2 and record and record[0] == (0, "SECTION"):
section = value
else:
record.append((code, value))
_assert(len(front_lines) >= 4, "DXF contains no native front projection")
x_coordinates = [float(value) for record in front_lines for code, value in record if code in (10, 11)]
y_coordinates = [float(value) for record in front_lines for code, value in record if code in (20, 21)]
dxf_width = max(x_coordinates) - min(x_coordinates)
dxf_height = max(y_coordinates) - min(y_coordinates)
_near(dxf_width, 100 * page.Scale)
_near(dxf_height, 50 * page.Scale)
_assert(all(v.getStatusString() == "Valid" for v in page.Views), "Views failed after model edit")
artifact(model.save_project(doc, out / "associative-drawing.FCStd"))
if App.GuiUp:
exported_svg = artifact(model.export_drawing(page, out / "associative-drawing.svg"))
_assert(model._svg_has_geometry(exported_svg), "Exported SVG has no projected model geometry")
artifact(model.export_drawing(page, out / "associative-drawing.pdf"))
return {"views": 3, "model_change_recomputed": True, "gui_exports": bool(App.GuiUp),
"direct_dxf_after_rapid_edits": True, "dxf_front_width_mm": dxf_width,
"dxf_front_height_mm": dxf_height}
def complete_examples():
volumes = {}
for kind in ("bracket", "plate", "shaft", "pulley"):
doc = model.make_demo(kind)
body = doc.getObject("Body")
_valid(body)
_assert(all(s.FullyConstrained for s in _sketches(body)), "Example sketch not fully constrained")
volumes[kind] = body.Shape.Volume
return {"volumes_mm3": volumes}
tests = [
("box_volume_constraints", box_volume_constraints),
("box_edit_undo_redo", box_edit_undo_redo),
("creation_undo_redo", creation_undo_redo),
("cylinder_material", cylinder_material),
("plate_parametric_holes", plate_parametric_holes),
("atomic_dimensions_and_rejection", atomic_dimensions_and_rejection),
("invalid_dimension_inputs", invalid_dimension_inputs),
("free_sketch_dimensions", free_sketch_dimensions),
("all_origin_planes", all_origin_planes),
("pocket_and_rollback", pocket_and_rollback),
("save_and_reopen", save_and_reopen),
("step_round_trip", step_round_trip),
("solid_export_formats", solid_export_formats),
("flat_exports_world_geometry_and_history", lambda: _flat_exports_world_geometry_and_history(out)),
("export_rejects_non_solids", export_rejects_non_solids),
("export_rejects_cached_failed_feature", export_rejects_cached_failed_feature),
("assembly_solver_links_bom", assembly_solver_links_bom),
("assembly_reopen_step", assembly_reopen_step),
("drawing_associativity", drawing_associativity),
("complete_examples", complete_examples),
]
try:
for name, fn in tests:
check(name, fn)
finally:
for name in set(App.listDocuments()) - initial_docs:
App.closeDocument(name)
if active_name in App.listDocuments():
App.setActiveDocument(active_name)
if not App.GuiUp:
report["skipped"] += 1
report["checks"].append({"name": "drawing_pdf_svg_gui", "passed": None, "status": "skipped",
"reason": "Headless run: full page PDF/SVG export needs GUI; native page/DXF verified."})
report["status"] = "passed" if report["failed"] == 0 else "failed"
report["success"] = report["failed"] == 0
(out / "kernel-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
if __name__ == "__main__":
output = sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).resolve().parents[1] / "artifacts" / "kernel")
result = run(output)
print(json.dumps({k: result[k] for k in ("status", "passed", "failed", "skipped")}, ensure_ascii=False))
if result["failed"]:
for item in result["checks"]:
if item["status"] == "failed":
print(item["name"] + ": " + item["error"])
raise SystemExit(0 if result["success"] else 1)