Files
Qinventor/tests/projection_suite.py
T

531 lines
25 KiB
Python

"""Real-kernel regression checks for associated sketch projections, no GUI."""
import json
import sys
import traceback
from pathlib import Path
import FreeCAD as App
import Part
import Sketcher
SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor"
if str(SOURCE) not in sys.path:
sys.path.insert(0, str(SOURCE))
import qi_projection as projection
import qi_extrude as extrude
def _near(actual, expected, tolerance=1e-7):
if abs(actual - expected) > tolerance * max(1., abs(expected)):
raise AssertionError("Expected %.12g, obtained %.12g" % (expected, actual))
def _valid(obj):
assert not any(str(flag).lower().startswith(("invalid", "error")) for flag in obj.State), obj.State
def _base(name, body=False, plane="XY"):
doc = App.newDocument(name)
doc.Label = "Проверка проекции"
doc.UndoMode = 1
if body:
group = doc.addObject("PartDesign::Body", "Body")
source = group.newObject("PartDesign::AdditiveBox", "Base")
sketch = group.newObject("Sketcher::SketchObject", "Sketch")
else:
group = None
source = doc.addObject("Part::Box", "Base")
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
source.Label = "Основание"
source.Length, source.Width, source.Height = 20, 10, 8
sketch.Label = "Эскиз"
if plane == "XZ":
sketch.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90)
elif plane == "YZ":
sketch.Placement.Rotation = App.Rotation(App.Vector(1, 1, 1), 120)
doc.recompute()
_valid(source)
return doc, source, sketch, group
def _top(source):
return next("Face%d" % (i + 1) for i, face in enumerate(source.Shape.Faces)
if face.normalAt(0, 0).z > .99)
def run(output_dir):
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
checks, documents = [], []
def check(name, function):
try:
evidence = function()
checks.append({"name": name, "passed": True, "evidence": evidence})
except Exception:
checks.append({"name": name, "passed": False, "error": traceback.format_exc()})
def base(name, **kwargs):
values = _base(name, **kwargs)
documents.append(values[0].Name)
return values
def defining_and_update():
doc, source, sketch, _ = base("ProjectionDefining")
ids = projection.project_geometry(sketch, source, [_top(source)])
assert len(ids) == 4 and len(sketch.ExternalGeometry) == 1
assert len(sketch.Shape.Wires) == 1 and sketch.Shape.Wires[0].isClosed()
_near(extrude._profile_face(sketch).Area, 200)
source.Length = 30
doc.recompute()
_near(sketch.Shape.Length, 80)
_near(extrude._profile_face(sketch).Area, 300)
_valid(sketch)
return {"native_ids": ids, "source_length_mm": source.Length.Value,
"updated_profile_area_mm2": 300}
def single_edge_and_vertex():
doc, source, sketch, _ = base("ProjectionElements")
edge = projection.project_geometry(sketch, source, "Edge1", construction=True)
vertex = projection.project_geometry(sketch, source, "Vertex1", construction=True)
assert edge and vertex and len(projection._links(sketch)) == 2
assert sketch.Shape.isNull()
_valid(sketch)
return {"edge_ids": edge, "vertex_ids": vertex, "profile_excludes_references": True}
def construction_exclusion():
doc, source, sketch, _ = base("ProjectionConstruction")
ids = projection.project_geometry(sketch, source, _top(source), construction=True)
assert len(ids) == 4 and sketch.Shape.isNull()
try:
extrude._profile_face(sketch)
except ValueError:
pass
else:
raise AssertionError("Construction-only projection must not be extrudable")
source.Length = 30
doc.recompute()
perimeter = sum(g.toShape().Length for g in sketch.ExternalGeo[2:])
_near(perimeter, 80)
return {"reference_perimeter_mm": perimeter, "extrusion_rejected": True}
def attachment_extrusion():
doc, source, sketch, body = base("ProjectionAttached", body=True)
top = _top(source)
sketch.AttachmentSupport = (source, [top])
sketch.MapMode = "FlatFace"
doc.recompute()
projection.project_geometry(sketch, source, top)
feature = extrude.create_extrusion(sketch, 3, operation="Join")
_near(feature.Shape.Volume, 2200)
source.Length = 30
doc.recompute()
_near(feature.Shape.Volume, 3300)
_valid(feature)
_valid(body)
return {"map_mode": sketch.MapMode, "updated_join_volume_mm3": feature.Shape.Volume}
def body_selection_captures_tip():
doc, source, sketch, body = base("ProjectionBodySource", body=True)
projection.project_geometry(sketch, body, _top(source))
assert sketch.ExternalGeometry[0][0] == source
assert body not in sketch.OutList
return {"source_type": source.TypeId, "body_reference_cycle_avoided": True}
def rotated_planes():
volumes = {}
for plane, face_dimension in [("XZ", 160), ("YZ", 80)]:
doc, source, sketch, body = base("Projection" + plane, body=True, plane=plane)
wanted = App.Vector(0, -1, 0) if plane == "XZ" else App.Vector(1, 0, 0)
face = next("Face%d" % (i + 1) for i, f in enumerate(source.Shape.Faces)
if f.normalAt(0, 0).dot(wanted) > .99)
projection.project_geometry(sketch, source, face)
_near(extrude._profile_face(sketch).Area, face_dimension)
feature = extrude.create_extrusion(sketch, 2, operation="New Solid")
_near(feature.Shape.Volume, face_dimension * 2)
_valid(feature)
volumes[plane] = feature.Shape.Volume
return {"projection_extrusion_volumes_mm3": volumes}
def all_loops_and_holes():
doc = App.newDocument("ProjectionHole")
documents.append(doc.Name)
doc.UndoMode = 1
body = doc.addObject("PartDesign::Body", "Body")
source = body.newObject("PartDesign::Feature", "Base")
source.Shape = Part.makeBox(20, 10, 8).cut(Part.makeCylinder(2, 8, App.Vector(10, 5, 0)))
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
doc.recompute()
top = _top(source)
sketch.AttachmentSupport = (source, [top])
sketch.MapMode = "FlatFace"
doc.recompute()
ids = projection.project_geometry(sketch, source, top)
assert len(ids) == 5 and len(sketch.Shape.Wires) == 2
face = extrude._profile_face(sketch)
_near(face.Area, 200 - 4 * 3.141592653589793)
feature = extrude.create_extrusion(sketch, 2, operation="Join")
_near(feature.Shape.Volume, face.Area * 10)
_valid(feature)
return {"projected_edges": len(ids), "wires": 2, "hole_preserved_volume_mm3": feature.Shape.Volume}
def part_placement():
doc, source, sketch, body = base("ProjectionPartPlacement", body=True)
part = doc.addObject("App::Part", "Component")
part.addObject(body)
part.Placement = App.Placement(App.Vector(40, -25, 18), App.Rotation(App.Vector(1, 1, 1), 57))
body.Placement = App.Placement(App.Vector(3, 4, 6), App.Rotation(App.Vector(0, 0, 1), 23))
top = _top(source)
sketch.AttachmentSupport = (source, [top])
sketch.MapMode = "FlatFace"
doc.recompute()
projection.project_geometry(sketch, source, top)
_near(extrude._profile_face(sketch).Area, 200)
feature = extrude.create_extrusion(sketch, 3, operation="Join")
_near(feature.Shape.Volume, 2200)
source.Length = 30
doc.recompute()
_near(feature.Shape.Volume, 3300)
_valid(body)
_valid(feature)
return {"placed_part_and_body": True, "parametric_world_placement_volume_mm3": feature.Shape.Volume}
def circle_and_arc_unlink():
doc = App.newDocument("ProjectionCurves")
documents.append(doc.Name)
doc.UndoMode = 1
cylinder = doc.addObject("Part::Cylinder", "Cylinder")
cylinder.Radius, cylinder.Height = 4, 8
circle_sketch = doc.addObject("Sketcher::SketchObject", "CircleSketch")
doc.recompute()
projection.project_geometry(circle_sketch, cylinder, _top(cylinder))
_near(extrude._profile_face(circle_sketch).Area, 16 * 3.141592653589793)
projection.break_links(circle_sketch)
assert circle_sketch.Geometry[0].isDerivedFrom("Part::GeomCircle")
cylinder.Radius = 6
doc.recompute()
_near(extrude._profile_face(circle_sketch).Area, 16 * 3.141592653589793)
arc_source = doc.addObject("Part::Feature", "Arc")
arc_source.Shape = Part.Arc(App.Vector(0, 0, 0), App.Vector(5, 5, 0), App.Vector(10, 0, 0)).toShape()
arc_sketch = doc.addObject("Sketcher::SketchObject", "ArcSketch")
doc.recompute()
projection.project_geometry(arc_sketch, arc_source, "Edge1")
before = arc_sketch.Shape.Length
projection.break_links(arc_sketch)
assert arc_sketch.Geometry[0].isDerivedFrom("Part::GeomArcOfCircle")
_near(arc_sketch.Shape.Length, before)
_valid(arc_sketch)
return {"circle_radius_preserved_mm": 4, "arc_length_preserved_mm": before}
def reference_dimension_unlink():
doc, source, sketch, _ = base("ProjectionReferenceDimension")
ids = projection.project_geometry(sketch, source, _top(source), construction=True)
geometry = sketch.ExternalGeo[-ids[0] - 1]
dimension = Sketcher.Constraint("Distance", ids[0], geometry.toShape().Length)
index = sketch.addConstraint(dimension)
sketch.setDriving(index, False)
sketch.renameConstraint(index, "ReferenceLength")
doc.recompute()
before = sketch.Constraints[index].Value
mapping = projection.break_links(sketch, [ids[0]])
assert sketch.Constraints[index].First == mapping[ids[0]]
assert not sketch.Constraints[index].Driving
assert sketch.Constraints[index].Name == "ReferenceLength"
_near(sketch.Constraints[index].Value, before)
_valid(sketch)
return {"reference_dimension_mm": before, "name_and_reference_mode_retained": True}
def unlink_preserves_geometry():
doc, source, sketch, _ = base("ProjectionUnlink")
ids = projection.project_geometry(sketch, source, _top(source))
before = sketch.Shape.copy()
mapping = projection.break_links(sketch, [ids[0]])
assert len(mapping) == 4 and not sketch.ExternalGeometry and sketch.GeometryCount == 4
_near(sketch.Shape.Length, before.Length)
_near(extrude._profile_face(sketch).Area, 200)
source.Length = 40
doc.recompute()
_near(extrude._profile_face(sketch).Area, 200)
_valid(sketch)
return {"old_to_independent_ids": mapping, "independent_area_mm2": 200}
def unlink_construction():
doc, source, sketch, _ = base("ProjectionUnlinkConstruction")
projection.project_geometry(sketch, source, _top(source), construction=True)
projection.break_links(sketch)
assert sketch.Shape.isNull() and all(sketch.getConstruction(i) for i in range(4))
assert not sketch.ExternalGeometry
return {"independent_construction_curves": 4, "profile_empty": True}
def grouped_source_links():
doc, source, sketch, _ = base("ProjectionGroupedSource")
face_ids = projection.project_geometry(sketch, source, _top(source))
edge_ids = projection.project_geometry(sketch, source, "Edge2", construction=True)
assert len(projection._links(sketch)) == 2
projection.break_links(sketch, [face_ids[0]])
assert len(projection._links(sketch)) == 1
assert projection._links(sketch)[0] == (source, "Edge2")
assert len(projection.linked_geometry_ids(sketch)) == len(edge_ids)
_near(extrude._profile_face(sketch).Area, 200)
source.Length = 35
doc.recompute()
_near(extrude._profile_face(sketch).Area, 200)
_valid(sketch)
return {"same_source_independent_face_and_retained_edge_link": True}
def vertex_selection_and_unlink():
from types import SimpleNamespace
doc, source, sketch, _ = base("ProjectionVertexUnlink")
ids = projection.project_geometry(sketch, source, "Vertex1", construction=True)
linked_file = output / "vertex-projection-linked.FCStd"
doc.saveAs(str(linked_file))
App.closeDocument(doc.Name)
doc = App.openDocument(str(linked_file))
documents.append(doc.Name)
doc.UndoMode = 1
source, sketch = doc.Base, doc.Sketch
assert projection.linked_geometry_ids(sketch) == ids
vertex_number = next(i + 1 for i in range(100)
if sketch.getGeoVertexIndex(i)[0] == ids[0])
selection = SimpleNamespace(Object=sketch, SubElementNames=["Vertex%d" % vertex_number])
assert projection.selected_external_ids(sketch, [selection]) == ids
point = sketch.ExternalGeo[-ids[0] - 1].toShape().Vertexes[0].Point
line = sketch.addGeometry(Part.LineSegment(point, point + App.Vector(3, 4, 0)), False)
constraint = sketch.addConstraint(Sketcher.Constraint("Coincident", line, 1, ids[0], 1))
sketch.renameConstraint(constraint, "AtProjectedPoint")
doc.recompute()
mapping = projection.break_links(sketch, ids)
assert len(mapping) == 1 and sketch.getConstruction(mapping[ids[0]])
actual = sketch.Geometry[mapping[ids[0]]].toShape().Vertexes[0].Point
_near((point - actual).Length, 0)
assert not sketch.ExternalGeometry
assert sketch.Constraints[constraint].Second == mapping[ids[0]]
doc.undo()
doc.recompute()
assert projection.linked_geometry_ids(sketch) == ids
assert sketch.Constraints[constraint].Second == ids[0]
doc.redo()
doc.recompute()
assert not sketch.ExternalGeometry
assert sketch.Constraints[constraint].Second == mapping[ids[0]]
independent_file = output / "vertex-projection-independent.FCStd"
doc.saveAs(str(independent_file))
App.closeDocument(doc.Name)
restored = App.openDocument(str(independent_file))
documents.append(restored.Name)
restored.Base.Placement.Base = App.Vector(15, 0, 0)
restored.recompute()
assert not projection.linked_geometry_ids(restored.Sketch)
assert restored.Sketch.Constraints[constraint].Second == mapping[ids[0]]
assert restored.Sketch.Constraints[constraint].Name == "AtProjectedPoint"
_near((restored.Sketch.Geometry[line].StartPoint - point).Length, 0)
_valid(restored.Sketch)
return {"vertex_selection_name": selection.SubElementNames[0], "independent_point_preserved": True,
"point_constraints_undo_redo_save_reopen": True}
def unlink_constraints_and_remaining_links():
doc, source, sketch, _ = base("ProjectionUnlinkConstraints")
ids = projection.project_geometry(sketch, source, _top(source), construction=True)
other = doc.addObject("Part::Box", "Other")
other.Placement.Base = App.Vector(30, 0, 0)
doc.recompute()
remaining = projection.project_geometry(sketch, other, "Edge2", construction=True)
endpoint = sketch.ExternalGeo[-ids[0] - 1].StartPoint
line = sketch.addGeometry(Part.LineSegment(endpoint, endpoint + App.Vector(3, 4, 0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", line, 1, ids[0], 1))
length = sketch.addConstraint(Sketcher.Constraint("Distance", line, 5.))
sketch.renameConstraint(length, "LineLength")
sketch.setExpression("Constraints.LineLength", "Base.Width / 2")
before_expressions = dict(sketch.ExpressionEngine)
second = sketch.addGeometry(Part.LineSegment(App.Vector(0, 0, 0), App.Vector(0, -3, 0)), True)
sketch.addConstraint(Sketcher.Constraint("PointOnObject", second, 1, remaining[0]))
doc.recompute()
_valid(sketch)
before_constraints = len(sketch.Constraints)
before_start = sketch.Geometry[line].StartPoint
mapping = projection.break_links(sketch, [ids[0]])
assert len(sketch.Constraints) == before_constraints
assert len(sketch.ExternalGeometry) == 1 and sketch.ExternalGeometry[0][0] == other
assert dict(sketch.ExpressionEngine) == before_expressions
assert sketch.Constraints[0].Second == mapping[ids[0]]
assert sketch.Constraints[-1].Second == -3
source.Length = 40
doc.recompute()
_near((sketch.Geometry[line].StartPoint - before_start).Length, 0)
_valid(sketch)
path = output / "projection-constraints.FCStd"
doc.saveAs(str(path))
App.closeDocument(doc.Name)
restored = App.openDocument(str(path))
documents.append(restored.Name)
assert dict(restored.Sketch.ExpressionEngine) == before_expressions
assert restored.Sketch.Constraints[0].Second == mapping[ids[0]]
assert restored.Sketch.Constraints[-1].Second == -3
assert len(restored.Sketch.Constraints) == before_constraints
_valid(restored.Sketch)
return {"constraints_retained": before_constraints, "expression": "Base.Width / 2",
"remaining_external_constraint_remapped": -3, "save_reopen_preserved": True}
def undo_redo():
doc, source, sketch, _ = base("ProjectionUndo")
projection.project_geometry(sketch, source, _top(source))
doc.undo()
doc.recompute()
assert not sketch.ExternalGeometry and sketch.Shape.isNull()
doc.redo()
doc.recompute()
_near(sketch.Shape.Length, 60)
projection.break_links(sketch)
doc.undo()
doc.recompute()
assert len(sketch.ExternalGeometry) == 1 and sketch.GeometryCount == 0
doc.redo()
doc.recompute()
assert not sketch.ExternalGeometry and sketch.GeometryCount == 4
_near(sketch.Shape.Length, 60)
return {"projection_and_unlink_undo_redo": True}
def save_reopen():
doc, source, sketch, _ = base("ProjectionPersistence")
projection.project_geometry(sketch, source, _top(source))
path = output / "associated-projection.FCStd"
doc.saveAs(str(path))
App.closeDocument(doc.Name)
reopened = App.openDocument(str(path))
documents.append(reopened.Name)
reopened.Base.Length = 30
reopened.recompute()
_near(extrude._profile_face(reopened.Sketch).Area, 300)
projection.break_links(reopened.Sketch)
independent = output / "independent-projection.FCStd"
reopened.saveAs(str(independent))
App.closeDocument(reopened.Name)
final = App.openDocument(str(independent))
documents.append(final.Name)
final.Base.Length = 40
final.recompute()
_near(extrude._profile_face(final.Sketch).Area, 300)
assert not final.Sketch.ExternalGeometry
return {"associated_file": str(path), "independent_file": str(independent),
"independent_area_after_reload_mm2": 300}
def rejects_atomically():
doc, source, sketch, _ = base("ProjectionRejects")
cases = [(["Edge1", "Edge9999"], None), (["Face6", "Face6"], None),
(["Solid1"], None), ([], None)]
rejected = 0
for names, _ in cases:
before = sketch.dumpContent()
try:
projection.project_geometry(sketch, source, names)
except ValueError:
rejected += 1
else:
raise AssertionError("Invalid projection selection accepted: %s" % names)
assert sketch.dumpContent() == before
projection.project_geometry(sketch, source, _top(source))
before = sketch.dumpContent()
for selection in [[-3, 0], [-999], []]:
try:
projection.break_links(sketch, selection)
except ValueError:
rejected += 1
else:
raise AssertionError("Invalid unlink selection accepted")
assert sketch.dumpContent() == before
return {"invalid_batches_rejected_without_mutation": rejected}
def cyclic_and_cross_document():
doc, source, sketch, _ = base("ProjectionCycle")
dependent = doc.addObject("Part::Feature", "Dependent")
dependent.addProperty("App::PropertyLink", "Profile")
dependent.Profile = sketch
dependent.Shape = source.Shape.copy()
second, foreign, _, _ = base("ProjectionForeign")
count = 0
for obj in [dependent, foreign, sketch]:
before = sketch.dumpContent()
try:
projection.project_geometry(sketch, obj, "Edge1")
except ValueError:
count += 1
else:
raise AssertionError("Cyclic or cross-document reference accepted")
assert sketch.dumpContent() == before
return {"invalid_reference_sources_rejected": count}
def solver_failure_rolls_back():
doc, source, sketch, _ = base("ProjectionSolverRollback")
line = sketch.addGeometry(Part.LineSegment(App.Vector(1, 2, 0), App.Vector(6, 2, 0)), False)
sketch.addConstraint(Sketcher.Constraint("Distance", line, 5.))
sketch.addConstraint(Sketcher.Constraint("Distance", line, 7.))
doc.recompute()
before_constraints = [(c.Type, c.First, c.FirstPos, c.Second, c.SecondPos, c.Value)
for c in sketch.Constraints]
before_start, before_end = sketch.Geometry[0].StartPoint, sketch.Geometry[0].EndPoint
try:
projection.project_geometry(sketch, source, _top(source))
except ValueError:
pass
else:
raise AssertionError("Conflicting sketch must reject projection")
assert not sketch.ExternalGeometry and sketch.GeometryCount == 1
assert [(c.Type, c.First, c.FirstPos, c.Second, c.SecondPos, c.Value)
for c in sketch.Constraints] == before_constraints
_near((sketch.Geometry[0].StartPoint - before_start).Length, 0)
_near((sketch.Geometry[0].EndPoint - before_end).Length, 0)
return {"failed_native_solver_transaction_rolled_back": True}
def selection_mapping():
from types import SimpleNamespace
doc, source, sketch, _ = base("ProjectionSelectionMapping")
ids = projection.project_geometry(sketch, source, _top(source))
item = SimpleNamespace(Object=sketch, SubElementNames=["ExternalEdge1", "ExternalEdge4"])
assert projection.selected_external_ids(sketch, [item]) == [ids[0], ids[3]]
try:
projection.selected_external_ids(sketch, [SimpleNamespace(Object=sketch, SubElementNames=["Edge1"])])
except ValueError:
pass
else:
raise AssertionError("Internal edge must not be treated as external")
return {"ExternalEdge1": -3, "ExternalEdge4": -6}
for name, function in [
("Defining projection is linked and regenerates", defining_and_update),
("Single edge and vertex projections", single_edge_and_vertex),
("Construction references regenerate and are excluded from extrusion", construction_exclusion),
("Face-supported sketch and parametric extrusion", attachment_extrusion),
("Body selection captures preceding solid feature", body_selection_captures_tip),
("XZ and YZ world placement", rotated_planes),
("Face projection includes inner hole boundaries", all_loops_and_holes),
("Face attachment in rotated Part and Body preserves world placement", part_placement),
("Break Link retains native circles and arcs", circle_and_arc_unlink),
("Break Link preserves native reference dimensions", reference_dimension_unlink),
("Break Link retains geometry and stops dependency", unlink_preserves_geometry),
("Break Link preserves construction state", unlink_construction),
("Selective unlink preserves grouped links to the same source", grouped_source_links),
("Native vertex selection and independent point unlink", vertex_selection_and_unlink),
("Break Link preserves constraints expressions and other references", unlink_constraints_and_remaining_links),
("Projection and Break Link native undo redo", undo_redo),
("Associated and independent geometry save reopen", save_reopen),
("Invalid mixed batches reject atomically", rejects_atomically),
("Cyclic and cross-document sources reject", cyclic_and_cross_document),
("Native solver failure aborts projection transaction", solver_failure_rolls_back),
("Native external selection names map to GeoIds", selection_mapping),
]:
check(name, function)
for name in set(documents):
if name in App.listDocuments():
App.closeDocument(name)
result = {"passed": all(c["passed"] for c in checks), "checks": checks,
"summary": {"total": len(checks), "passed": sum(c["passed"] for c in checks),
"failed": sum(not c["passed"] for c in checks)}, "gui_exercised": False}
(output / "projection-suite.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
if __name__ == "__main__":
result = run(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] / "artifacts" / "projection")
print(json.dumps(result["summary"]))