Files
Qinventor/tests/sketch_offset_suite.py
T

523 lines
26 KiB
Python
Raw 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 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("<Root>" + sketch.Content + "</Root>")
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"]))