523 lines
26 KiB
Python
523 lines
26 KiB
Python
"""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"]))
|