"""Real FreeCAD/OCCT extrusion regression scenarios, usable headless or GUI.""" import json import math import os 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_extrude as extrude def _near(actual, expected, tolerance=1e-6): if abs(actual - expected) > tolerance * max(1.0, abs(expected)): raise AssertionError("Expected %.9g, obtained %.9g" % (expected, actual)) def _doc(name): doc = App.newDocument(name) doc.Label = "Проверка выдавливания" doc.UndoMode = 1 body = doc.addObject("PartDesign::Body", "Body") body.Label = "Тело" return doc, body def _rectangle(body, width=20, height=20, placement=None): sk = body.newObject("Sketcher::SketchObject", "Sketch") sk.Label = "Эскиз" points = [App.Vector(0, 0, 0), App.Vector(width, 0, 0), App.Vector(width, height, 0), App.Vector(0, height, 0)] for i in range(4): sk.addGeometry(Part.LineSegment(points[i], points[(i + 1) % 4]), False) for i in range(4): sk.addConstraint(Sketcher.Constraint("Coincident", i, 2, (i + 1) % 4, 1)) for i in (0, 2): sk.addConstraint(Sketcher.Constraint("Horizontal", i)) for i in (1, 3): sk.addConstraint(Sketcher.Constraint("Vertical", i)) sk.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1)) width_index = sk.addConstraint(Sketcher.Constraint("Distance", 0, float(width))) height_index = sk.addConstraint(Sketcher.Constraint("Distance", 1, float(height))) sk.renameConstraint(width_index, "Width") sk.renameConstraint(height_index, "Height") if placement is not None: sk.Placement = placement body.Document.recompute() return sk def _circle(body, radius=3, z=0, x=0, y=0): sk = body.newObject("Sketcher::SketchObject", "SketchCircle") sk.Label = "Эскиз окружности" sk.addGeometry(Part.Circle(App.Vector(0, 0, 0), App.Vector(0, 0, 1), radius), False) index = sk.addConstraint(Sketcher.Constraint("Radius", 0, radius)) sk.renameConstraint(index, "Radius") sk.Placement = App.Placement(App.Vector(x, y, z), App.Rotation()) body.Document.recompute() return sk, index def _baseline(name): doc, body = _doc(name) sk = _rectangle(body) pad = body.newObject("PartDesign::Pad", "Pad") pad.Profile, pad.Length = sk, 10 body.Tip = pad doc.recompute() _near(pad.Shape.Volume, 4000) return doc, body, pad def _plane(doc, z=10, slope=0, size=100, placement=None): obj = doc.addObject("Part::Feature", "TerminationPlane") obj.Label = "Конечная плоскость" obj.Shape = Part.makePlane(size, size, App.Vector(-size / 2, -size / 2, 0)) obj.Placement = placement or App.Placement(App.Vector(0, 0, z), App.Rotation(App.Vector(0, 1, 0), -math.degrees(math.atan(slope)))) doc.recompute() return obj def run(output_dir): output = Path(output_dir) output.mkdir(parents=True, exist_ok=True) if App.GuiUp: # Exercise the product's actual Russian native labels as well as our # task form; the test uses its own isolated user-config file. import qi_localization qi_localization.install() results = [] documents = [] def case(name, action): before = set(App.listDocuments()) try: details = action() or {} results.append({"name": name, "passed": True, **details}) except Exception as exc: results.append({"name": name, "passed": False, "error": str(exc), "traceback": traceback.format_exc()}) finally: for created in set(App.listDocuments()) - before: App.closeDocument(created) def oriented(direction, rotation, lengths): doc, body = _doc("ExtrudePlane" + direction) documents.append(doc.Name) sk = _rectangle(body, 10, 6, App.Placement(App.Vector(), rotation)) obj = extrude.create_extrusion(sk, 4, direction=direction) _near(obj.Shape.Volume, 240) bbox = obj.Shape.BoundBox for actual, expected in zip((bbox.XLength, bbox.YLength, bbox.ZLength), lengths): _near(actual, expected) if rotation.Angle == 0: _near(bbox.ZMin, {"Forward": 0, "Reverse": -4, "Symmetric": -2}[direction]) return {"volume": obj.Shape.Volume, "bbox": [bbox.XLength, bbox.YLength, bbox.ZLength]} case("XY forward", lambda: oriented("Forward", App.Rotation(), (10, 6, 4))) case("XY reverse", lambda: oriented("Reverse", App.Rotation(), (10, 6, 4))) case("XY symmetric", lambda: oriented("Symmetric", App.Rotation(), (10, 6, 4))) case("XZ plane placement", lambda: oriented("Forward", App.Rotation(App.Vector(1, 0, 0), 90), (10, 4, 6))) case("YZ plane placement", lambda: oriented("Forward", App.Rotation(App.Vector(0, 1, 0), 90), (4, 6, 10))) def annulus(): doc, body = _doc("ExtrudeAnnulus") documents.append(doc.Name) sk, _ = _circle(body, 3) sk.addGeometry(Part.Circle(App.Vector(), App.Vector(0, 0, 1), 10), False) doc.recompute() obj = extrude.create_extrusion(sk, 4) _near(obj.Shape.Volume, math.pi * 91 * 4) assert len(obj.Shape.Solids) == 1 return {"volume": obj.Shape.Volume} case("Nested wires preserve hole", annulus) def parametric(): doc, body = _doc("ExtrudeParametric") documents.append(doc.Name) sk, radius_index = _circle(body, 5) obj = extrude.create_extrusion(sk, 2) _near(obj.Shape.Volume, math.pi * 50) sk.setDatum(radius_index, App.Units.Quantity("8 mm")) doc.recompute() _near(obj.Shape.Volume, math.pi * 128) obj.setExpression("Length", "3 mm") doc.recompute() _near(obj.Shape.Volume, math.pi * 192) path = output / "parametric-extrusion.FCStd" doc.saveAs(str(path)) doc_name, feature_name = doc.Name, obj.Name App.closeDocument(doc_name) documents.remove(doc_name) reopened = App.openDocument(str(path)) documents.append(reopened.Name) restored = reopened.getObject(feature_name) assert isinstance(restored.Proxy, extrude.ExtrusionProxy) restored.Profile.setDatum(radius_index, App.Units.Quantity("9 mm")) reopened.recompute() _near(restored.Shape.Volume, math.pi * 243) return {"volumeAfterReopenAndEdit": restored.Shape.Volume, "file": str(path)} case("Sketch edits, length expressions and FCStd reopen", parametric) def boolean(operation, extent="Distance", direction="Forward"): doc, body, pad = _baseline("Extrude" + operation.replace(" ", "") + extent.replace(" ", "")) documents.append(doc.Name) if operation == "Join": sk = _rectangle(body, 10, 10, App.Placement(App.Vector(0, 0, 10), App.Rotation())) length, expected = 5, 4500 elif operation == "Cut": sk, _ = _circle(body, 3, z=10, x=10, y=10) length = 3 expected = 4000 - math.pi * 9 * (10 if extent == "Through all" else 3) else: sk = _rectangle(body, 10, 10, App.Placement(App.Vector(5, 5, 0), App.Rotation())) length, expected = 4, 400 # Creating an unconsumed sketch must preserve the preceding solid Tip. assert body.Tip == pad obj = extrude.create_extrusion(sk, length, operation, direction, extent) assert obj.BaseFeature == pad assert body.Tip == obj _near(obj.Shape.Volume, expected) return {"volume": obj.Shape.Volume, "base": obj.BaseFeature.Name} case("Join against previous solid Body Tip", lambda: boolean("Join")) case("Cut reverse against previous solid Body Tip", lambda: boolean("Cut", direction="Reverse")) case("Cut through all", lambda: boolean("Cut", "Through all", "Reverse")) case("True OCCT intersection", lambda: boolean("Intersect")) def through_recompute(): doc, body, pad = _baseline("ExtrudeThroughRecompute") documents.append(doc.Name) sk, _ = _circle(body, 3, x=10, y=10) obj = extrude.create_extrusion(sk, 1, "Cut", "Symmetric", "Through all") pad.Length = 16 doc.recompute() _near(obj.Shape.Volume, 6400 - math.pi * 9 * 16) return {"volumeAfterBaseChange": obj.Shape.Volume} case("Through all recalculates extent after base change", through_recompute) def separate(): doc, body, pad = _baseline("ExtrudeSeparate") documents.append(doc.Name) body.Placement = App.Placement(App.Vector(2, 3, 4), App.Rotation()) sk, _ = _circle(body, 3, x=50) obj = extrude.create_extrusion(sk, 5, "New Solid") target_body = extrude._body_of(obj) assert target_body != body assert obj.BaseFeature is None assert pad in body.Group assert body.Tip == pad _near(obj.Shape.Volume, math.pi * 45) _near(target_body.Placement.Base.x, 2) _near(target_body.Shape.BoundBox.Center.x, 52) _near(target_body.Shape.BoundBox.Center.y, 3) _near(target_body.Shape.BoundBox.Center.z, 6.5) return {"bodies": len([x for x in doc.Objects if x.isDerivedFrom("PartDesign::Body")])} case("New Solid is independent and preserves body placement", separate) def separate_container(): doc, body, pad = _baseline("ExtrudeSeparateContainer") container = doc.addObject("App::Part", "Container") container.Label = "Деталь с несколькими телами" container.Placement = App.Placement(App.Vector(30, 40, 50), App.Rotation(App.Vector(0, 0, 1), 90)) container.addObject(body) body.Placement = App.Placement(App.Vector(2, 3, 4), App.Rotation()) sk, _ = _circle(body, 3, x=50) obj = extrude.create_extrusion(sk, 5, "New Solid") new_body = extrude._body_of(obj) assert new_body in container.Group and body in container.Group _near(new_body.Placement.Base.x, 2) _near(new_body.getGlobalPlacement().Base.x, 27) _near(new_body.getGlobalPlacement().Base.y, 42) _near(new_body.getGlobalPlacement().Base.z, 54) _near(obj.Shape.Volume, math.pi * 45) assert body.Tip == pad and extrude._is_solid(body) and extrude._is_solid(new_body) return {"samePartSiblingAndLocalPlacementPreserved": True} case("New Solid remains a sibling inside transformed App Part", separate_container) def invalid(): doc, body = _doc("ExtrudeInvalid") documents.append(doc.Name) sk = body.newObject("Sketcher::SketchObject", "OpenSketch") sk.addGeometry(Part.LineSegment(App.Vector(), App.Vector(4, 0, 0)), False) doc.recompute() original = set(obj.Name for obj in doc.Objects) for value in (0, -1, float("inf"), float("nan"), 4): try: extrude.create_extrusion(sk, value) except ValueError: pass else: raise AssertionError("Invalid input accepted") assert original == set(obj.Name for obj in doc.Objects) return {"objectsUnchanged": True} case("Open profile and invalid lengths leave document unchanged", invalid) def disjoint(): doc, body, pad = _baseline("ExtrudeDisjoint") documents.append(doc.Name) sk, _ = _circle(body, 3, x=100) original = set(obj.Name for obj in doc.Objects) for operation in ("Join", "Cut", "Intersect"): try: extrude.create_extrusion(sk, 4, operation) except ValueError: pass else: raise AssertionError("Disjoint %s should be rejected" % operation) assert original == set(obj.Name for obj in doc.Objects) return {"objectsUnchanged": True} case("Disjoint booleans are rejected before mutation", disjoint) def undo(): doc, body = _doc("ExtrudeUndo") documents.append(doc.Name) sk = _rectangle(body, 10, 6) initial_tip = body.Tip obj = extrude.create_extrusion(sk, 4) name = obj.Name doc.undo() doc.recompute() assert doc.getObject(name) is None assert body.Tip == initial_tip doc.redo() doc.recompute() _near(doc.getObject(name).Shape.Volume, 240) return {"undoRedo": True} case("Native undo redo retains geometry", undo) def asymmetric(): doc, body = _doc("ExtrudeAsymmetric") sk = _rectangle(body, 10, 6) obj = extrude.create_extrusion(sk, 4, direction="Asymmetric", length2=7) _near(obj.Shape.Volume, 660) _near(obj.Shape.BoundBox.ZMin, -7) _near(obj.Shape.BoundBox.ZMax, 4) assert str(obj.Direction) == "Асимметрично" obj.setExpression("Length2", "2 * Length") doc.recompute() _near(obj.Shape.Volume, 720) doc.openTransaction("Изменить расстояния") obj.Length = 6 doc.recompute() doc.commitTransaction() _near(obj.Shape.Volume, 1080) doc.undo() doc.recompute() _near(obj.Shape.Volume, 720) path = output / "asymmetric-extrusion.FCStd" doc.saveAs(str(path)) name = obj.Name App.closeDocument(doc.Name) reopened = App.openDocument(str(path)) restored = reopened.getObject(name) assert str(restored.Direction) == "Асимметрично" restored.Length = 5 reopened.recompute() _near(restored.Shape.Volume, 900) return {"length2ExpressionUndoAndReopen": True, "volumeAfterReopen": restored.Shape.Volume} case("Asymmetric persisted distances, expressions, undo and reopen", asymmetric) def to_face(slope=0, reverse=False): doc, body = _doc("ExtrudeToFace") sk = _rectangle(body, 10, 6) target = _plane(doc, -10 if reverse else 10, -slope if reverse else slope) obj = extrude.create_extrusion(sk, 1, direction="Reverse" if reverse else "Forward", extent="To face", target_face=(target, ["Face1"])) expected = 60 * (10 + 5 * slope) _near(obj.Shape.Volume, expected) _near(body.Shape.Volume, expected) assert obj.TargetFace[0] == target and list(obj.TargetFace[1]) == ["Face1"] assert obj.getTypeIdOfProperty("TargetFace") == "App::PropertyLinkSubGlobal" assert str(obj.Extent) == "До грани" caps = [face for face in obj.Shape.Faces if isinstance(face.Surface, Part.Plane) and abs(face.Surface.Axis.dot(target.Shape.Faces[0].Surface.Axis)) > 1 - 1e-8] assert any(abs(face.common(target.Shape).Area - face.Area) < 1e-7 for face in caps) target.Placement.Base.z += -2 if reverse else 2 doc.recompute() _near(obj.Shape.Volume, expected + 120) return {"slope": slope, "reverse": reverse, "volumeAfterPlaneMove": obj.Shape.Volume} case("To face planar termination and support movement", to_face) case("To face exact oblique termination", lambda: to_face(0.5)) case("To face exact oblique reverse termination", lambda: to_face(0.5, True)) def to_face_hole(): doc, body = _doc("ExtrudeToFaceHole") sk, _ = _circle(body, 3) sk.addGeometry(Part.Circle(App.Vector(), App.Vector(0, 0, 1), 10), False) target = _plane(doc, 20, 0.5) obj = extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, "Face1")) _near(obj.Shape.Volume, math.pi * 91 * 20) assert len(obj.Shape.Solids) == 1 return {"obliqueAnnulusVolume": obj.Shape.Volume} case("To face exact oblique cap preserves nested holes", to_face_hole) def extend_face(): doc, body = _doc("ExtrudeExtendFace") sk = _rectangle(body, 10, 6) target = _plane(doc, 10, 0.5, size=2) original = set(obj.Name for obj in doc.Objects) try: extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, ["Face1"])) except ValueError as exc: assert "Продолжить грань" in str(exc) else: raise AssertionError("A finite face covering only part of the profile was accepted") assert original == set(obj.Name for obj in doc.Objects) obj = extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, ["Face1"]), extend_face=True) _near(obj.Shape.Volume, 750) assert obj.ExtendFace return {"finiteBoundaryRejected": True, "extendedPlaneVolume": obj.Shape.Volume} case("To face finite support coverage and explicit extension", extend_face) def cut_to_face(): doc, body, pad = _baseline("ExtrudeCutToFace") sk, _ = _circle(body, 3, z=10, x=10, y=10) bottom = next("Face%d" % (index + 1) for index, face in enumerate(pad.Shape.Faces) if isinstance(face.Surface, Part.Plane) and abs(face.CenterOfMass.z) < 1e-7 and abs(face.Surface.Axis.z) > 0.9) # Native viewport selection may name the Body instead of its Tip. obj = extrude.create_extrusion(sk, 1, "Cut", "Reverse", "To face", target_face=(body, [bottom])) _near(obj.Shape.Volume, 4000 - math.pi * 90) assert obj.TargetFace[0] == obj.BaseFeature == pad assert extrude._is_solid(body) assert not obj.SupportObjects doc.undo() doc.recompute() assert body.Tip == pad doc.redo() doc.recompute() _near(body.Tip.Shape.Volume, 4000 - math.pi * 90) return {"sameBodyFaceReferenceWithoutDependencyCycle": True} case("To face cut references previous solid and survives undo redo", cut_to_face) def holed_target_boundary(): doc, body = _doc("ExtrudeHoledTarget") sk = _rectangle(body, 10, 6) target = _plane(doc, 10) hole = Part.Wire(Part.makeCircle(1, App.Vector(5, 3, 10))) inner = Part.Face(hole) target.Shape = target.Shape.cut(inner) doc.recompute() _near(target.Shape.Area, 10000 - math.pi) original = set(item.Name for item in doc.Objects) try: extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, ["Face1"])) except ValueError as exc: assert "не перекрывает" in str(exc), str(exc) else: raise AssertionError("A hole in the finite termination face was ignored") assert original == set(item.Name for item in doc.Objects) obj = extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, ["Face1"]), extend_face=True) _near(obj.Shape.Volume, 600) return {"terminationFaceHoleRespected": True} case("To face finite boundary honors holes in termination support", holed_target_boundary) def target_placement(): doc, body = _doc("ExtrudeTargetPlacement") transform = App.Placement(App.Vector(50, 30, 20), App.Rotation(App.Vector(1, 0, 0), 35)) body.Placement = transform sk = _rectangle(body, 10, 6) container = doc.addObject("App::Part", "TargetContainer") container.Label = "Опора" container.Placement = transform target = _plane(doc, 10, 0.5) container.addObject(target) doc.recompute() obj = extrude.create_extrusion(sk, 1, extent="To face", target_face=(target, ["Face1"])) _near(obj.Shape.Volume, 750) assert extrude._is_solid(body) path = output / "to-face-extrusion.FCStd" doc.saveAs(str(path)) feature_name, target_name, container_name = obj.Name, target.Name, container.Name App.closeDocument(doc.Name) reopened = App.openDocument(str(path)) restored = reopened.getObject(feature_name) assert restored.TargetFace[0] == reopened.getObject(target_name) assert str(restored.Extent) == "До грани" reopened.getObject(target_name).Placement.Base.z += 2 reopened.recompute() _near(restored.Shape.Volume, 870) # The persisted LinkSub must observe parent-container placement changes. reopened.getObject(container_name).Placement.Base.z += 2 reopened.recompute() # The moved support is tilted relative to global z: local offset is # 2*cos(35 deg), with no local x component for this X-axis rotation. _near(restored.Shape.Volume, 870 + 120 * math.cos(math.radians(35))) return {"globalPlacementsAndReopen": True, "volumeAfterReopen": restored.Shape.Volume} case("To face across transformed containers and FCStd reopen", target_placement) def invalid_targets(): doc, body = _doc("ExtrudeInvalidTargets") sk = _rectangle(body, 10, 6) behind = _plane(doc, -10) crossing = _plane(doc, -2, 0.5) parallel = _plane(doc, placement=App.Placement(App.Vector(10, 0, 0), App.Rotation(App.Vector(0, 1, 0), 90))) curved = doc.addObject("Part::Feature", "CurvedTarget") curved.Label = "Цилиндрическая опора" curved.Shape = Part.makeCylinder(20, 10, App.Vector(0, 0, 10)) doc.recompute() original = set(obj.Name for obj in doc.Objects) for link in (None, (behind, ["Face1"]), (crossing, ["Face1"]), (parallel, ["Face1"]), (behind, ["Edge1"]), (behind, ["Face999"]), (curved, ["Face1"])): try: extrude.create_extrusion(sk, 1, extent="To face", target_face=link) except ValueError as exc: assert any("А" <= char <= "я" for char in str(exc)), str(exc) else: raise AssertionError("Invalid target accepted: %s" % (None if not link else (link[0].Name, link[1]),)) assert original == set(obj.Name for obj in doc.Objects) for direction, extent, second in (("Asymmetric", "Distance", -2), ("Asymmetric", "Through all", 2), ("Symmetric", "To face", 2), ("Invalid", "Distance", 2)): try: extrude.create_extrusion(sk, 1, direction=direction, extent=extent, length2=second, target_face=(behind, ["Face1"])) except ValueError: pass else: raise AssertionError("Invalid direction or second length accepted") assert original == set(obj.Name for obj in doc.Objects) return {"invalidTargetsAndDirectionsLeaveDocumentUnchanged": True} case("Invalid target surfaces, directions and second length rejected in Russian", invalid_targets) def to_next(): doc, body, pad = _baseline("ExtrudeToNext") sk, _ = _circle(body, 3, z=10, x=10, y=10) obj = extrude.create_extrusion(sk, 1, "Cut", "Reverse", "To next", terminator=body) _near(obj.Shape.Volume, 4000 - math.pi * 90) assert obj.Terminator == pad assert str(obj.Extent) == "До следующей" pad.Length = 15 doc.recompute() # Sketch remains at z10; backward cut terminates at the bottom z0. _near(obj.Shape.Volume, 6000 - math.pi * 90) return {"nextWholePlanarFace": True, "volumeAfterBaseChange": obj.Shape.Volume} case("To next true planar termination on preceding solid", to_next) def next_selected_terminator(): doc, body, pad = _baseline("ExtrudeSelectedNext") sk = _rectangle(body, 10, 6, App.Placement(App.Vector(0, 0, 10), App.Rotation())) target = doc.addObject("Part::Feature", "TerminationSolid") target.Label = "Тело завершения" target.Shape = Part.makeBox(20, 20, 5, App.Vector(-1, -1, 20)) doc.recompute() obj = extrude.create_extrusion(sk, 1, extent="To next", terminator=target) _near(obj.Shape.Volume, 4600) assert obj.Terminator == target target.Placement.Base.z = 3 doc.recompute() _near(obj.Shape.Volume, 4780) return {"selectedTerminatorRegenerates": True, "volume": obj.Shape.Volume} case("To next explicit terminator tracks nearest complete face", next_selected_terminator) def next_ambiguous(): doc, body, pad = _baseline("ExtrudeAmbiguousNext") sk = _rectangle(body, 10, 6, App.Placement(App.Vector(0, 0, 10), App.Rotation())) target = doc.addObject("Part::Feature", "AmbiguousPlanes") target.Shape = Part.makeCompound([_plane(doc, 20, 0.5).Shape, _plane(doc, 25, -0.5).Shape]) # The two finite supporting faces swap order at x5. doc.recompute() original = set(obj.Name for obj in doc.Objects) try: extrude.create_extrusion(sk, 1, extent="To next", terminator=target) except ValueError as exc: assert "неоднозначна" in str(exc), str(exc) else: raise AssertionError("Crossing termination planes accepted as one nearest face") assert original == set(obj.Name for obj in doc.Objects) return {"ambiguousCrossingPlanesRejected": True} case("To next rejects ambiguous crossing support planes", next_ambiguous) def next_curved_rejected(): doc, body, pad = _baseline("ExtrudeCurvedNext") sk = _rectangle(body, 10, 6, App.Placement(App.Vector(0, 0, 10), App.Rotation())) target = doc.addObject("Part::Feature", "CurvedTerminator") target.Shape = Part.makeCylinder(20, 10, App.Vector(5, 3, 20)) doc.recompute() original = set(item.Name for item in doc.Objects) try: extrude.create_extrusion(sk, 1, extent="To next", terminator=target) except ValueError as exc: assert "криволинейной" in str(exc) else: raise AssertionError("Curved next-face terminator accepted") assert original == set(item.Name for item in doc.Objects) return {"curvedNextLimitationExplicit": True} case("To next explicitly rejects curved terminators", next_curved_rejected) def change_operation_bodies(): doc, body, pad = _baseline("ExtrudeOperationBodyEdit") sk, _ = _circle(body, 3, z=10, x=10, y=10) obj = extrude.create_extrusion(sk, 3) doc.openTransaction("Перевести в новое тело") extrude._update_feature(obj, (sk, 3, "New Solid", "Forward", "Distance", pad, 10, None, False, None)) doc.commitTransaction() new_body_name = extrude._body_of(obj).Name assert extrude._body_of(obj) != body and body.Tip == pad _near(obj.Shape.Volume, math.pi * 27) _near(body.Shape.Volume, 4000) assert obj.BaseFeature is None doc.undo() doc.recompute() assert body.Tip == obj and extrude._body_of(obj) == body assert doc.getObject(new_body_name) is None _near(obj.Shape.Volume, 4000 + math.pi * 27) doc.redo() doc.recompute() assert body.Tip == pad and extrude._body_of(obj) != body doc.openTransaction("Вернуть вырезание в исходное тело") base = extrude._edit_base(obj) assert base == pad extrude._update_feature(obj, (sk, 3, "Cut", "Reverse", "Distance", base, 10, None, False, None)) doc.commitTransaction() assert extrude._body_of(obj) == body and body.Tip == obj assert doc.getObject(new_body_name) is None _near(obj.Shape.Volume, 4000 - math.pi * 27) doc.undo() doc.recompute() assert body.Tip == pad and extrude._body_of(obj) != body _near(obj.Shape.Volume, math.pi * 27) return {"operationChangeMovesFeatureAndUndoRestoresBodies": True} case("Changing operation moves real history between solid bodies with undo redo", change_operation_bodies) if App.GuiUp: def preview_cancel(): doc, body, pad = _baseline("ExtrudePreviewCancel") documents.append(doc.Name) sk = _rectangle(body, 10, 10, App.Placement(App.Vector(0, 0, 10), App.Rotation())) initial = set(obj.Name for obj in doc.Objects) visible = pad.Visibility panel = extrude.ExtrusionTaskPanel(doc, sk) assert panel._preview is not None _near(panel._preview.Shape.Volume, 5000) panel.length.setValue(5) panel.update_preview() _near(panel._preview.Shape.Volume, 4500) assert panel.reject() assert initial == set(obj.Name for obj in doc.Objects) assert pad.Visibility == visible return {"previewDeleted": True, "visibilityRestored": True} case("GUI real preview update and cancel rollback", preview_cancel) def preview_accept(): doc, body, pad = _baseline("ExtrudePreviewAccept") documents.append(doc.Name) sk = _rectangle(body, 10, 10, App.Placement(App.Vector(0, 0, 10), App.Rotation())) panel = extrude.ExtrusionTaskPanel(doc, sk) panel.length.setValue(5) assert panel.accept(), panel.status.text() obj = body.Tip _near(obj.Shape.Volume, 4500) assert not any(x.Name.startswith("QI_ExtrudePreview") for x in doc.Objects) assert not pad.Visibility doc.undo() doc.recompute() assert not any(x.Name.startswith("QI_Extrusion") for x in doc.Objects) assert not any(x.Name.startswith("QI_ExtrudePreview") for x in doc.Objects) doc.redo() doc.recompute() obj = body.Tip _near(obj.Shape.Volume, 4500) editor = extrude.ExtrusionTaskPanel(doc, obj.Profile, obj) editor.length.setValue(7) assert editor.accept(), editor.status.text() _near(obj.Shape.Volume, 4700) doc.undo() doc.recompute() _near(obj.Shape.Volume, 4500) return {"acceptPreviewDeleted": True, "editUndo": True} case("GUI accept, parameter edit and undo", preview_accept) def preview_placement(): doc, body = _doc("ExtrudePreviewPlacement") documents.append(doc.Name) body.Placement = App.Placement(App.Vector(2, 3, 4), App.Rotation()) sk = _rectangle(body, 10, 6, App.Placement(App.Vector(0, 0, 10), App.Rotation(App.Vector(1, 0, 0), 90))) panel = extrude.ExtrusionTaskPanel(doc, sk) panel.length.setValue(4) panel.update_preview() bb = panel._preview.Shape.BoundBox for actual, expected in zip((bb.XLength, bb.YLength, bb.ZLength), (10, 4, 6)): _near(actual, expected) for actual, expected in zip((bb.XMin, bb.YMin, bb.ZMin), (2, -1, 14)): _near(actual, expected) panel.reject() return {"rotatedSketchAndTranslatedBody": True} case("GUI preview respects sketch and body placements", preview_placement) def task_dialog(): import FreeCADGui as Gui from PySide import QtCore doc, body = _doc("ExtrudeTaskDialog") documents.append(doc.Name) sk = _rectangle(body, 10, 6) Gui.Selection.clearSelection() Gui.Selection.addSelection(sk) panel = extrude.show_dialog() assert isinstance(panel.getStandardButtons(), int) QtCore.QCoreApplication.processEvents() assert Gui.Control.activeDialog() assert panel.profiles.currentData() == sk.Name _, _, QtWidgets = extrude._gui_modules() main = Gui.getMainWindow() main.resize(1440, 960) mdi = main.findChild(QtWidgets.QMdiArea) if mdi and mdi.activeSubWindow(): mdi.activeSubWindow().showMaximized() for dock in main.findChildren(QtWidgets.QDockWidget): if dock.objectName() == "Tasks": main.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock) dock.show() dock.raise_() Gui.activeDocument().activeView().viewAxonometric() Gui.activeDocument().activeView().fitAll() try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest QtTest.QTest.qWait(250) assert panel.form.isVisible(), "Extrusion task form is hidden" assert panel.form.width() > 120 and panel.form.height() > 120 image_path = output / "extrusion-task-panel.png" main.grab().save(str(image_path)) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] assert boxes, "Native task buttons are hidden" boxes[0].button(QtWidgets.QDialogButtonBox.Cancel).click() QtTest.QTest.qWait(50) assert not Gui.Control.activeDialog() assert not any(x.Name.startswith("QI_ExtrudePreview") for x in doc.Objects) panel = extrude.show_dialog() QtTest.QTest.qWait(50) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] assert boxes boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(50) assert not Gui.Control.activeDialog() _near(body.Tip.Shape.Volume, 600) assert not any(x.Name.startswith("QI_ExtrudePreview") for x in doc.Objects) feature = body.Tip assert Gui.activeDocument().setEdit(feature.Name) QtTest.QTest.qWait(50) assert Gui.Control.activeDialog() editor = main.findChild(QtWidgets.QDoubleSpinBox, "QI_ExtrudeLength") assert editor is not None and editor.isVisible() editor.setValue(15) QtTest.QTest.qWait(100) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(50) assert not Gui.Control.activeDialog() assert Gui.activeDocument().getInEdit() is None _near(feature.Shape.Volume, 900) return {"nativeTaskDialog": True, "nativeOkCancelButtons": True, "nativeFeatureEdit": True, "screenshot": str(image_path)} case("GUI Control.showDialog and selected profile", task_dialog) def asymmetric_panel(): doc, body = _doc("ExtrudeAsymmetricPanel") sk = _rectangle(body, 10, 6) panel = extrude.ExtrusionTaskPanel(doc, sk) panel.direction.setCurrentIndex(panel.direction.findData("Asymmetric")) panel.length.setValue(4) panel.length2.setValue(7) panel.update_preview() _near(panel._preview.Shape.Volume, 660) _near(panel._preview.Shape.BoundBox.ZMin, -7) assert panel.length2.isEnabled() panel.swap_distances.click() panel.update_preview() _near(panel._preview.Shape.BoundBox.ZMin, -4) _near(panel._preview.Shape.BoundBox.ZMax, 7) assert panel.accept(), panel.status.text() obj = body.Tip _near(obj.Shape.Volume, 660) assert str(obj.Direction) == "Асимметрично" _near(obj.Length2.Value, 4) editor = extrude.ExtrusionTaskPanel(doc, obj.Profile, obj) editor.length2.setValue(2) editor.update_preview() _near(editor._preview.Shape.Volume, 540) assert editor.accept(), editor.status.text() _near(obj.Shape.Volume, 540) doc.undo() doc.recompute() _near(obj.Shape.Volume, 660) assert not any(item.Name.startswith("QI_ExtrudePreview") for item in doc.Objects) return {"asymmetricSwapPreviewAcceptEditUndo": True} case("GUI asymmetric distances and direction inversion", asymmetric_panel) def native_target_selection(): import FreeCADGui as Gui from PySide import QtCore try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest _, _, QtWidgets = extrude._gui_modules() doc, body = _doc("ExtrudeNativeFaceSelection") sk = _rectangle(body, 10, 6) target = _plane(doc, 10, 0.5, size=2) second = _plane(doc, 14, 0, size=2) second.Label = "Вторая конечная плоскость" second.Visibility = False Gui.Selection.clearSelection() Gui.Selection.addSelection(sk) panel = extrude.show_dialog() panel.extent.setCurrentIndex(panel.extent.findData("To face")) assert not panel.length.isEnabled() panel.pick_target.click() assert panel._picking_target Gui.Selection.addSelection(doc.Name, target.Name, "Face1") QtTest.QTest.qWait(120) assert not panel._picking_target assert panel._target_face[0] == target assert panel.extend_face.isChecked(), "A small support should automatically extend" panel.update_preview() expected = panel._preview.Shape.Volume _near(expected, 750) target.Visibility = False Gui.Selection.clearSelection() Gui.activeDocument().activeView().viewAxonometric() Gui.activeDocument().activeView().fitAll() QtTest.QTest.qWait(200) assert panel.form.isVisible() assert panel.target.text() == "Конечная плоскость · грань 1" assert panel.extent.currentText() == "До грани" image_path = output / "extrusion-to-oblique-face.png" main = Gui.getMainWindow() main.grab().save(str(image_path)) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] assert boxes boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(50) assert not Gui.Control.activeDialog() obj = body.Tip _near(obj.Shape.Volume, expected) assert obj.TargetFace[0] == target and obj.ExtendFace assert Gui.activeDocument().setEdit(obj.Name) QtTest.QTest.qWait(80) button = next(item for item in main.findChildren(QtWidgets.QPushButton) if item.isVisible() and item.text() == "Выбрать в модели") button.click() Gui.Selection.addSelection(doc.Name, second.Name, "Face1") QtTest.QTest.qWait(120) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(80) assert not Gui.Control.activeDialog() and Gui.activeDocument().getInEdit() is None _near(obj.Shape.Volume, 840) assert obj.TargetFace[0] == second doc.undo() doc.recompute() _near(obj.Shape.Volume, 750) assert obj.TargetFace[0] == target assert not any(item.Name.startswith("QI_ExtrudePreview") for item in doc.Objects) return {"nativeFaceSelectionAndEditUndo": True, "previewEqualsAcceptedSolid": True, "screenshot": str(image_path)} case("GUI native selection of oblique face, automatic extension and edit undo", native_target_selection) def next_panel(): import FreeCADGui as Gui doc, body, pad = _baseline("ExtrudeNextPanel") sk = _rectangle(body, 10, 6, App.Placement(App.Vector(0, 0, 10), App.Rotation())) target = doc.addObject("Part::Feature", "NextSolid") target.Label = "Тело завершения" target.Shape = Part.makeBox(20, 20, 5, App.Vector(-1, -1, 20)) doc.recompute() panel = extrude.ExtrusionTaskPanel(doc, sk) panel.extent.setCurrentIndex(panel.extent.findData("To next")) panel.pick_target.click() Gui.Selection.addSelection(target) panel.update_preview() expected = panel._preview.Shape.Volume _near(expected, 4600) assert panel.target.text() == target.Label assert panel.accept(), panel.status.text() obj = body.Tip assert obj.Terminator == target _near(obj.Shape.Volume, expected) doc.undo() doc.recompute() assert body.Tip == pad assert not any(item.Name.startswith("QI_ExtrudePreview") for item in doc.Objects) return {"nextTerminatorSelectionPreviewAcceptUndo": True} case("GUI selected next-face terminator", next_panel) def target_error_rollback(): doc, body = _doc("ExtrudeInvalidFacePanel") sk = _rectangle(body, 10, 6) crossing = _plane(doc, -2, 0.5) original = set(item.Name for item in doc.Objects) panel = extrude.ExtrusionTaskPanel(doc, sk) panel.extent.setCurrentIndex(panel.extent.findData("To face")) panel.set_target(crossing, "Face1") panel.update_preview() assert "пересекает профиль" in panel.status.text() assert not panel.accept() assert original == set(item.Name for item in doc.Objects) assert panel.reject() assert original == set(item.Name for item in doc.Objects) return {"invalidTargetAcceptRollbackAndCancel": True} case("GUI invalid target rolls back and cancels cleanly", target_error_rollback) def native_operation_edit(): import FreeCADGui as Gui try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest _, _, QtWidgets = extrude._gui_modules() doc, body, pad = _baseline("ExtrudeNativeOperationEdit") sk, _ = _circle(body, 3, z=10, x=10, y=10) obj = extrude.create_extrusion(sk, 3) assert Gui.activeDocument().setEdit(obj.Name) QtTest.QTest.qWait(50) main = Gui.getMainWindow() operation = main.findChild(QtWidgets.QComboBox, "QI_ExtrudeOperation") operation.setCurrentIndex(operation.findData("New Solid")) QtTest.QTest.qWait(120) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] assert boxes boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(80) assert not Gui.Control.activeDialog() and Gui.activeDocument().getInEdit() is None assert body.Tip == pad and extrude._body_of(obj) != body _near(obj.Shape.Volume, math.pi * 27) assert Gui.activeDocument().setEdit(obj.Name) QtTest.QTest.qWait(50) operation = main.findChild(QtWidgets.QComboBox, "QI_ExtrudeOperation") direction = main.findChild(QtWidgets.QComboBox, "QI_ExtrudeDirection") operation.setCurrentIndex(operation.findData("Cut")) direction.setCurrentIndex(direction.findData("Reverse")) QtTest.QTest.qWait(120) boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()] boxes[0].button(QtWidgets.QDialogButtonBox.Ok).click() QtTest.QTest.qWait(80) assert not Gui.Control.activeDialog() and Gui.activeDocument().getInEdit() is None assert body.Tip == obj and extrude._body_of(obj) == body _near(obj.Shape.Volume, 4000 - math.pi * 27) doc.undo() doc.recompute() assert body.Tip == pad and extrude._body_of(obj) != body assert not any(item.Name.startswith("QI_ExtrudePreview") for item in doc.Objects) return {"nativeOperationChangeAndBodyMigrationUndo": True} case("GUI native operation edits migrate solid history and undo", native_operation_edit) for name in documents: if name in App.listDocuments(): App.closeDocument(name) report = {"passed": all(item["passed"] for item in results), "count": len(results), "gui": bool(App.GuiUp), "scenarios": results} (output / "extrude-suite.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report def _autorun(): report = run(os.environ.get("QI_EXTRUDE_OUTPUT", str(SOURCE.parents[1] / "artifacts" / "extrude"))) print(json.dumps(report, ensure_ascii=False)) if App.GuiUp and os.environ.get("QI_EXTRUDE_GUI_EXIT") == "1": from PySide import QtCore QtCore.QTimer.singleShot(200, QtCore.QCoreApplication.quit) if not report["passed"]: raise RuntimeError("Extrusion regression suite failed") if os.environ.get("QI_RUN_EXTRUDE_SUITE") == "1": if App.GuiUp: # Startup macros run before the native Task dock has been installed. # Let the real main window finish startup before testing showDialog. from PySide import QtCore QtCore.QTimer.singleShot(900, _autorun) else: _autorun()