Files

942 lines
45 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()