"""Real Sketcher/OCCT offset regressions; no GUI or sampled geometry.""" import json import math import sys import traceback import xml.etree.ElementTree as ET 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_sketch_offset as service import qi_extrude as extrude from qi_model import _transaction def _near(actual, expected, tolerance=1e-7): assert abs(actual - expected) <= tolerance * max(1., abs(expected)), (actual, expected) def _point(actual, expected): _near((actual - expected).Length, 0) def _valid(sketch): assert sketch.solve() == 0 assert not any(str(flag).lower().startswith(("invalid", "error")) for flag in sketch.State), sketch.State def run(output_dir): output = Path(output_dir) output.mkdir(parents=True, exist_ok=True) documents, checks = [], [] constraint = Sketcher.Constraint def base(name, body=False): doc = App.newDocument(name) doc.Label = "Проверка смещения" doc.UndoMode = 1 if body: group = doc.addObject("PartDesign::Body", "Body") sketch = group.newObject("Sketcher::SketchObject", "Sketch") else: sketch = doc.addObject("Sketcher::SketchObject", "Sketch") sketch.Label = "Эскиз" documents.append(doc.Name) return doc, sketch def circle(name, construction=False): doc, sketch = base(name) sketch.addGeometry(Part.Circle(App.Vector(20, 10, 0), App.Vector(0, 0, 1), 5), construction) sketch.addConstraint([constraint("DistanceX", 0, 3, 20), constraint("DistanceY", 0, 3, 10), constraint("Radius", 0, 5)]) doc.recompute() return doc, sketch def line(name): doc, sketch = base(name) sketch.addGeometry(Part.LineSegment(App.Vector(5, 7, 0), App.Vector(15, 7, 0)), False) sketch.addConstraint([constraint("DistanceX", 0, 1, 5), constraint("DistanceY", 0, 1, 7), constraint("Angle", 0, 0.), constraint("Distance", 0, 10)]) doc.recompute() return doc, sketch def rectangle(name, clockwise=False, body=False): doc, sketch = base(name, body) height = -10 if clockwise else 10 points = [App.Vector(0, 0, 0), App.Vector(20, 0, 0), App.Vector(20, height, 0), App.Vector(0, height, 0)] sketch.addGeometry([Part.LineSegment(points[i], points[(i + 1) % 4]) for i in range(4)], False) sketch.addConstraint([constraint("Coincident", i, 2, (i + 1) % 4, 1) for i in range(4)] + [constraint("Horizontal", 0), constraint("Vertical", 1), constraint("Horizontal", 2), constraint("Vertical", 3), constraint("Coincident", 0, 1, -1, 1), constraint("Distance", 0, 20), constraint("Distance", 1, 10)]) doc.recompute() return doc, sketch def check(name, function): try: checks.append({"name": name, "passed": True, "evidence": function()}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) def circle_association(): evidence = [] for distance in (2., -2.): doc, sketch = circle("OffsetCircleAssociation") result = service.offset(sketch, [0], distance) assert result["source_ids"] == [0] and result["created_ids"] == [1] assert not result["helper_ids"] and len(result["dimension_ids"]) == 1 _near(sketch.Geometry[1].Radius, 5 + distance) sketch.setDatum(2, App.Units.Quantity("8 mm")) sketch.setDatum(0, App.Units.Quantity("25 mm")) doc.recompute() _near(sketch.Geometry[1].Radius, 8 + distance) _point(sketch.Geometry[1].Center, App.Vector(25, 10, 0)) assert sketch.FullyConstrained _valid(sketch) evidence.append({"signed_distance_mm": distance, "updated_radius_mm": 8 + distance}) return evidence def dimension_expression(): doc, sketch = circle("OffsetExpression") result = service.offset(sketch, [0], 2) index = result["dimension_ids"][0] assert sketch.Constraints[index].Name == "Смещение" sketch.setDatum(index, App.Units.Quantity("3 mm")) doc.recompute() _near(sketch.Geometry[1].Radius, 8) parameters = doc.addObject("App::VarSet", "Parameters") parameters.addProperty("App::PropertyLength", "Offset", "Размеры", "Смещение") parameters.Offset = 4 sketch.setExpression("Constraints[%d]" % index, "Parameters.Offset") doc.recompute() _near(sketch.Geometry[1].Radius, 9) parameters.Offset = 6 doc.recompute() _near(sketch.Geometry[1].Radius, 11) _valid(sketch) return {"native_dimension_name": "Смещение", "expression_drives_offset_mm": 6} def signed_line_update(): evidence = [] for distance in (2., -2.): doc, sketch = line("OffsetSignedLine") result = service.offset(sketch, [0], distance, loop_select=False) assert result["created_ids"] == [1] and len(result["helper_ids"]) == 2 assert all(sketch.getConstruction(i) for i in result["helper_ids"]) _point(sketch.Geometry[1].StartPoint, App.Vector(5, 7 + distance, 0)) sketch.setDatum(3, App.Units.Quantity("20 mm")) doc.recompute() _point(sketch.Geometry[1].EndPoint, App.Vector(25, 7 + distance, 0)) sketch.setDatum(2, App.Units.Quantity("45 deg")) sketch.setDatum(0, App.Units.Quantity("8 mm")) doc.recompute() source, target = sketch.Geometry[0], sketch.Geometry[1] direction = source.EndPoint - source.StartPoint direction.normalize() normal = App.Vector(-direction.y, direction.x, 0) _point(target.StartPoint, source.StartPoint + normal * distance) _point(target.EndPoint, source.EndPoint + normal * distance) _near(sketch.Shape.Length, 40) assert sketch.FullyConstrained _valid(sketch) evidence.append({"signed_distance_mm": distance, "source_rotation_deg": 45, "source_length_mm": 20, "helper_geometry_excluded": True}) return evidence def polygon_association(): evidence = [] for clockwise in (False, True): for distance in (2., -2.): doc, sketch = rectangle("OffsetPolygon", clockwise) result = service.offset(sketch, [2], distance) assert result["source_ids"] == [0, 1, 2, 3] assert len(result["created_ids"]) == len(result["helper_ids"]) == 4 sketch.setDatum(9, App.Units.Quantity("30 mm")) doc.recompute() shape = Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]) _near(Part.Face(shape).Area, (30 + 2 * distance) * (10 + 2 * distance)) _near(shape.BoundBox.XMin, -distance) _near(shape.BoundBox.XMax, 30 + distance) assert sketch.FullyConstrained _valid(sketch) # Changing the native driving distance must retain the side. sketch.setDatum(result["dimension_ids"][0], App.Units.Quantity("3 mm")) doc.recompute() final = math.copysign(3., distance) shape = Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]) _near(Part.Face(shape).Area, (30 + 2 * final) * (10 + 2 * final)) assert shape.isClosed() and shape.isValid() evidence.append({"clockwise": clockwise, "signed_distance_mm": final, "offset_profile_area_mm2": Part.Face(shape).Area}) return evidence def loop_expansion_excludes_helpers(): doc, sketch = rectangle("OffsetLoopSelect") construction = sketch.addGeometry(Part.LineSegment(App.Vector(0, 0, 0), App.Vector(-4, -4, 0)), True) sketch.addGeometry(Part.Circle(App.Vector(50, 50, 0), App.Vector(0, 0, 1), 3), False) doc.recompute() result = service.offset(sketch, [0, 0], 2) assert result["source_ids"] == [0, 1, 2, 3] assert construction not in result["source_ids"] _valid(sketch) return {"one_selected_edge_expands_to": 4, "auxiliary_connectors_and_unrelated_circle_excluded": True} def inclined_polygon(): doc, sketch = base("OffsetInclinedTriangle") points = [App.Vector(0, 0, 0), App.Vector(20, 0, 0), App.Vector(10, 10, 0)] sketch.addGeometry([Part.LineSegment(points[i], points[(i + 1) % 3]) for i in range(3)], False) sketch.addConstraint([constraint("Coincident", i, 2, (i + 1) % 3, 1) for i in range(3)] + [constraint("Coincident", 0, 1, -1, 1), constraint("Horizontal", 0), constraint("Distance", 0, 20), constraint("DistanceX", 1, 2, 10), constraint("DistanceY", 1, 2, 10)]) doc.recompute() result = service.offset(sketch, [1], 2) assert len(result["created_ids"]) == len(result["helper_ids"]) == 3 sketch.setDatum(5, App.Units.Quantity("30 mm")) sketch.setDatum(7, App.Units.Quantity("15 mm")) doc.recompute() for helper in result["helper_ids"]: segment = sketch.Geometry[helper] _near((segment.EndPoint - segment.StartPoint).Length, 2) for constraint_id in result["constraint_ids"]: c = sketch.Constraints[constraint_id] if c.Type == "Parallel": target, source = sketch.Geometry[c.First], sketch.Geometry[c.Second] direction = source.EndPoint - source.StartPoint direction.normalize() _near(abs((target.StartPoint - source.StartPoint).cross(direction).z), 2) wire = Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]) assert wire.isClosed() and Part.Face(wire).isValid() _valid(sketch) assert sketch.FullyConstrained return {"inclined_edges_associated_after_source_resize": 3, "perpendicular_offset_mm": 2} def no_association(): doc, sketch = circle("OffsetIndependentCircle") result = service.offset(sketch, [0], 2, constrain=False) assert result["constraint_ids"] == result["helper_ids"] == result["dimension_ids"] == [] sketch.setDatum(2, App.Units.Quantity("8 mm")) doc.recompute() _near(sketch.Geometry[1].Radius, 7) doc, sketch = rectangle("OffsetIndependentPolygon") result = service.offset(sketch, [0, 1, 2, 3], 2, loop_select=False, constrain=False) assert len(result["constraint_ids"]) == 4 and not result["helper_ids"] before = Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]).BoundBox sketch.setDatum(9, App.Units.Quantity("30 mm")) doc.recompute() after = Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]).BoundBox _near(before.XMax, after.XMax) assert all(c.First in result["created_ids"] and c.Second in result["created_ids"] for c in sketch.Constraints[11:]) _valid(sketch) return {"unchecked_constrain_offset_removes_source_association": True, "independent_polygon_remains_closed": True} def undo_redo(): doc, sketch = rectangle("OffsetUndo") doc.clearUndos() result = service.offset(sketch, [0], 2) counts = sketch.GeometryCount, len(sketch.Constraints) assert doc.UndoCount == 1 doc.undo() doc.recompute() assert sketch.GeometryCount == 4 and len(sketch.Constraints) == 11 doc.redo() doc.recompute() assert (sketch.GeometryCount, len(sketch.Constraints)) == counts sketch.setDatum(9, App.Units.Quantity("30 mm")) doc.recompute() _near(Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]).BoundBox.XMax, 32) _valid(sketch) return {"native_undo_steps": 1, "redo_retains_association": True} def nested_cancel_and_error(): doc, sketch = line("OffsetOuterTransaction") doc.clearUndos() before = sketch.dumpContent() try: with _transaction(doc, "Предпросмотр смещения"): service.offset(sketch, [0], -2, False) raise RuntimeError("cancel") except RuntimeError: pass assert sketch.dumpContent() == before and doc.UndoCount == 0 try: with _transaction(doc, "Предпросмотр смещения"): service.offset(sketch, [0], -2, False) service.offset(sketch, [0], 0, False) except ValueError: pass else: raise AssertionError("Invalid preview accepted") assert sketch.dumpContent() == before and doc.UndoCount == 0 with _transaction(doc, "Создать смещение"): service.offset(sketch, [0], -2, False) assert doc.UndoCount == 1 _valid(sketch) return {"outer_cancel_restores_sketch": True, "inner_failure_aborts_outer_preview": True, "nested_commit_is_one_undo_step": True} def save_reopen(): evidence = [] for kind in ("circle", "line", "polygon"): doc, sketch = {"circle": circle, "line": line, "polygon": rectangle}[kind]("OffsetPersistence") result = service.offset(sketch, [0], -2 if kind == "line" else 2, loop_select=kind != "line") if kind == "circle": sketch.setExpression("Constraints[%d]" % result["dimension_ids"][0], "3 mm") doc.recompute() path = output / ("offset-" + kind + ".FCStd") doc.saveAs(str(path)) App.closeDocument(doc.Name) restored = App.openDocument(str(path)) documents.append(restored.Name) sketch = restored.Sketch sketch.setDatum({"circle": 2, "line": 3, "polygon": 9}[kind], App.Units.Quantity("8 mm" if kind == "circle" else "30 mm")) restored.recompute() if kind == "circle": _near(sketch.Geometry[1].Radius, 11) assert sketch.ExpressionEngine elif kind == "line": _point(sketch.Geometry[1].EndPoint, App.Vector(35, 5, 0)) else: _near(Part.Wire([sketch.Geometry[i].toShape() for i in result["created_ids"]]).BoundBox.XMax, 32) assert not hasattr(sketch, "Proxy") or sketch.Proxy is None _valid(sketch) evidence.append({"kind": kind, "saved_file": path.name, "native_source_edit_after_reopen": True}) return evidence def helper_visibility_persistence(): def visibility(sketch): # IsVisible is persisted natively but not exposed on ConstraintPy. # Read the authoritative native serialization without changing it. content = ET.fromstring("" + sketch.Content + "") elements = content.findall(".//Property[@name='Constraints']/ConstraintList/Constrain") assert len(elements) == len(sketch.Constraints) return [element.get("IsVisible") == "1" for element in elements] evidence = [] for kind in ("circle", "line", "polygon"): doc, sketch = {"circle": circle, "line": line, "polygon": rectangle}[kind]("OffsetVisibility") source_count = len(sketch.Constraints) for index in range(source_count): sketch.setVisibility(index, bool(index % 2)) source_visibility = visibility(sketch) doc.clearUndos() result = service.offset(sketch, [0], 2, loop_select=kind != "line") public = set(result["dimension_ids"]) hidden = set(result["constraint_ids"]) - public expected = visibility(sketch) assert expected[:source_count] == source_visibility assert public and hidden assert all(expected[index] for index in public) assert all(not expected[index] for index in hidden) assert all(sketch.getActive(index) for index in result["constraint_ids"]) assert sketch.FullyConstrained and doc.UndoCount == 1 _valid(sketch) doc.undo() doc.recompute() assert sketch.GeometryCount == (4 if kind == "polygon" else 1) assert len(sketch.Constraints) == source_count assert visibility(sketch) == source_visibility doc.redo() doc.recompute() assert visibility(sketch) == expected and sketch.FullyConstrained _valid(sketch) path = output / ("offset-visibility-" + kind + ".FCStd") doc.saveAs(str(path)) App.closeDocument(doc.Name) restored = App.openDocument(str(path)) documents.append(restored.Name) sketch = restored.Sketch assert visibility(sketch) == expected sketch.setDatum({"circle": 2, "line": 3, "polygon": 9}[kind], App.Units.Quantity("8 mm" if kind == "circle" else "30 mm")) sketch.setDatum(result["dimension_ids"][0], App.Units.Quantity("3 mm")) restored.recompute() if kind == "circle": _near(sketch.Geometry[result["created_ids"][0]].Radius, 11) elif kind == "line": _point(sketch.Geometry[result["created_ids"][0]].EndPoint, App.Vector(35, 10, 0)) else: wire = Part.Wire([sketch.Geometry[index].toShape() for index in result["created_ids"]]) _near(wire.BoundBox.XMax, 33) assert visibility(sketch) == expected and sketch.FullyConstrained assert all(sketch.getActive(index) for index in result["constraint_ids"]) _valid(sketch) evidence.append({"kind": kind, "hidden_association_labels": len(hidden), "driving_offset_visible": True, "source_visibility_unchanged": True, "native_undo_redo_and_save_reopen": True, "hidden_constraints_still_solve_source_and_distance_edits": True}) return evidence def extrusion_and_placement(): volumes = {} for plane in ("XY", "XZ", "YZ"): doc, sketch = rectangle("OffsetExtrusion" + plane, body=True) part = doc.addObject("App::Part", "Component") part.addObject(doc.Body) part.Placement = App.Placement(App.Vector(40, -25, 18), App.Rotation(App.Vector(1, 1, 1), 37)) 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() result = service.offset(sketch, [0], 2) assert len(sketch.Shape.Wires) == 2 and all(sketch.getConstruction(i) for i in result["helper_ids"]) feature = extrude.create_extrusion(sketch, 3, operation="New Solid") _near(feature.Shape.Volume, ((24 * 14) - 200) * 3) sketch.setDatum(9, App.Units.Quantity("30 mm")) doc.recompute() _near(feature.Shape.Volume, ((34 * 14) - 300) * 3) _valid(sketch) assert feature.Shape.isValid() and len(feature.Shape.Solids) == 1 volumes[plane] = feature.Shape.Volume return {"annular_profile_extrudes_in_placed_part": volumes, "construction_connectors_excluded": True} def rejects_atomically(): doc, sketch = circle("OffsetRejects") invalid = [([], 2, True, True), ([-1], 2, True, True), ([1], 2, True, True), ([True], 2, True, True), ([0.], 2, True, True), ([0], 0, True, True), ([0], float("nan"), True, True), ([0], float("inf"), True, True), ([0], True, True, True), ([0], -5, True, True), ([0], -8, True, True), ([0], 1e8, True, True), ([0], 2, 1, True), ([0], 2, True, 1)] doc.clearUndos() before = sketch.dumpContent() for ids, distance, loop, linked in invalid: try: service.offset(sketch, ids, distance, loop, linked) except ValueError as exc: assert any("а" <= char.lower() <= "я" for char in str(exc)), str(exc) else: raise AssertionError("Invalid offset accepted: %r" % ((ids, distance, loop, linked),)) assert sketch.dumpContent() == before and doc.UndoCount == 0 doc, sketch = rectangle("OffsetCollapse") before = sketch.dumpContent() for distance in (-5., -6., -20.): try: service.offset(sketch, [0], distance) except ValueError: pass else: raise AssertionError("Collapsed polygon accepted") assert sketch.dumpContent() == before return {"invalid_inputs_rejected_without_changes": len(invalid) + 3} def unsupported_topology(): rejected = 0 for label, points in [ ("Open", [(0, 0), (10, 0), (10, 5)]), ("Concave", [(0, 0), (20, 0), (20, 10), (10, 5), (0, 10), (0, 0)]), ("Crossing", [(0, 0), (20, 10), (0, 10), (20, 0), (0, 0)]), ("Collinear", [(0, 0), (10, 0), (20, 0), (20, 10), (0, 10), (0, 0)]), ("Branch", [(0, 0), (20, 0), (20, 10), (0, 10), (0, 0), (-3, -3)])]: doc, sketch = base("Offset" + label) for p, q in zip(points, points[1:]): sketch.addGeometry(Part.LineSegment(App.Vector(*p, 0), App.Vector(*q, 0)), False) doc.recompute() before = sketch.dumpContent() try: service.offset(sketch, [0], 2) except ValueError: rejected += 1 else: raise AssertionError("Unsupported topology accepted: " + label) assert sketch.dumpContent() == before doc, sketch = line("OffsetSingleLineLoopReject") before = sketch.dumpContent() try: service.offset(sketch, [0], 2) except ValueError: rejected += 1 else: raise AssertionError("Loop mode must refuse open line") assert sketch.dumpContent() == before doc, sketch = base("OffsetCurveReject") sketch.addGeometry(Part.Arc(App.Vector(0, 0, 0), App.Vector(5, 5, 0), App.Vector(10, 0, 0)), False) doc.recompute() before = sketch.dumpContent() try: service.offset(sketch, [0], 2, False) except ValueError: rejected += 1 else: raise AssertionError("Unsupported arc must not be a static fake offset") assert sketch.dumpContent() == before return {"unsupported_open_concave_branch_curve_topologies_rejected": rejected} def source_conflict(): doc, sketch = circle("OffsetExistingSolverConflict") sketch.addConstraint(constraint("Radius", 0, 8)) doc.recompute() before = sketch.dumpContent() count = doc.UndoCount try: service.offset(sketch, [0], 2) except ValueError as exc: assert "конфликт" in str(exc).lower() or "перестроить" in str(exc).lower() else: raise AssertionError("Native source solver conflict accepted") assert sketch.dumpContent() == before and doc.UndoCount == count return {"real_native_solver_conflict_rejected_atomically": True} for name, function in [ ("Circle offset native association and signed source edit", circle_association), ("Driving offset dimension and native expression", dimension_expression), ("Directed single-line association survives resize and rotation", signed_line_update), ("Closed polygon outward and inward association for both windings", polygon_association), ("Inventor-style loop expansion excludes auxiliary geometry", loop_expansion_excludes_helpers), ("Inclined polygon edges retain exact normal distances", inclined_polygon), ("Constrain Offset off produces independent geometry", no_association), ("Native offset undo redo retains source association", undo_redo), ("Preview cancellation errors and nested transaction rollback", nested_cancel_and_error), ("Save reopen and source edit need only native Sketcher", save_reopen), ("Only offset distance visible; native visibility survives history and persistence", helper_visibility_persistence), ("Closed defining offset extrudes on XY XZ YZ in placed Part", extrusion_and_placement), ("Invalid distance selection and collapse reject atomically", rejects_atomically), ("Unsupported topology rejects without copied fake geometry", unsupported_topology), ("Real conflicting source constraints reject offset", source_conflict), ]: check(name, function) for name in set(documents): if name in App.listDocuments(): App.closeDocument(name) result = {"passed": all(check["passed"] for check in checks), "checks": checks, "summary": {"total": len(checks), "passed": sum(check["passed"] for check in checks), "failed": sum(not check["passed"] for check in checks)}, "gui_exercised": False} (output / "sketch-offset-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" / "sketch-offset") print(json.dumps(result["summary"]))