"""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"]))