feat: establish reproducible FreeCAD web compatibility baseline
This commit is contained in:
410
scripts/freecad-sketcher-constraint-oracle.py
Normal file
410
scripts/freecad-sketcher-constraint-oracle.py
Normal file
@@ -0,0 +1,410 @@
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
|
||||
import FreeCAD as App
|
||||
import Part
|
||||
import Sketcher
|
||||
|
||||
|
||||
TOLERANCE = 1e-7
|
||||
|
||||
|
||||
def line(start_x, start_y, end_x, end_y):
|
||||
return Part.LineSegment(App.Vector(start_x, start_y, 0), App.Vector(end_x, end_y, 0))
|
||||
|
||||
|
||||
def point(x, y):
|
||||
return Part.Point(App.Vector(x, y, 0))
|
||||
|
||||
|
||||
def circle(center_x, center_y, radius):
|
||||
return Part.Circle(App.Vector(center_x, center_y, 0), App.Vector(0, 0, 1), radius)
|
||||
|
||||
|
||||
def distance(first, second):
|
||||
return math.hypot(first.x - second.x, first.y - second.y)
|
||||
|
||||
|
||||
def line_length(geometry):
|
||||
return distance(geometry.StartPoint, geometry.EndPoint)
|
||||
|
||||
|
||||
def constraint_types(sketch):
|
||||
return [str(constraint.Type) for constraint in sketch.Constraints]
|
||||
|
||||
|
||||
def solver_state(sketch, solve_status):
|
||||
return {
|
||||
"solveStatus": int(solve_status),
|
||||
"degreesOfFreedom": int(sketch.DoF),
|
||||
"fullyConstrained": bool(sketch.FullyConstrained),
|
||||
"conflicting": [int(value) for value in sketch.ConflictingConstraints],
|
||||
"redundant": [int(value) for value in sketch.RedundantConstraints],
|
||||
"partiallyRedundant": [int(value) for value in sketch.PartiallyRedundantConstraints],
|
||||
"malformed": [int(value) for value in sketch.MalformedConstraints],
|
||||
"constraintTypes": constraint_types(sketch),
|
||||
}
|
||||
|
||||
|
||||
def new_sketch(case_id):
|
||||
document = App.newDocument("Oracle_" + case_id.replace("-", "_"))
|
||||
sketch = document.addObject("Sketcher::SketchObject", "Sketch")
|
||||
sketch.MapMode = "Deactivated"
|
||||
return document, sketch
|
||||
|
||||
|
||||
def close_document(document):
|
||||
App.closeDocument(document.Name)
|
||||
|
||||
|
||||
def run_success(case_id, constraint_type, setup):
|
||||
document, sketch = new_sketch(case_id)
|
||||
try:
|
||||
residual = setup(sketch)
|
||||
solve_status = sketch.solve()
|
||||
document.recompute()
|
||||
state = solver_state(sketch, solve_status)
|
||||
measured_residual = float(residual(sketch))
|
||||
passed = (
|
||||
state["solveStatus"] == 0
|
||||
and not state["conflicting"]
|
||||
and not state["redundant"]
|
||||
and not state["malformed"]
|
||||
and math.isfinite(measured_residual)
|
||||
and abs(measured_residual) <= TOLERANCE
|
||||
)
|
||||
return {
|
||||
"id": case_id,
|
||||
"constraintType": constraint_type,
|
||||
"expected": "success",
|
||||
"observed": "success" if passed else "unexpected-failure",
|
||||
"residual": measured_residual,
|
||||
**state,
|
||||
}
|
||||
except Exception as error:
|
||||
return {
|
||||
"id": case_id,
|
||||
"constraintType": constraint_type,
|
||||
"expected": "success",
|
||||
"observed": "exception",
|
||||
"errorType": type(error).__name__,
|
||||
"error": str(error),
|
||||
}
|
||||
finally:
|
||||
close_document(document)
|
||||
|
||||
|
||||
def add_lines(sketch):
|
||||
return sketch.addGeometry([
|
||||
line(0, 0, 4, 0),
|
||||
line(0, 2, 3, 6),
|
||||
], False)
|
||||
|
||||
|
||||
def setup_coincident(sketch):
|
||||
add_lines(sketch)
|
||||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 2, 1, 1))
|
||||
return lambda value: distance(value.Geometry[0].EndPoint, value.Geometry[1].StartPoint)
|
||||
|
||||
|
||||
def setup_horizontal(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
|
||||
return lambda value: value.Geometry[0].EndPoint.y - value.Geometry[0].StartPoint.y
|
||||
|
||||
|
||||
def setup_vertical(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Vertical", 0))
|
||||
return lambda value: value.Geometry[0].EndPoint.x - value.Geometry[0].StartPoint.x
|
||||
|
||||
|
||||
def setup_parallel(sketch):
|
||||
add_lines(sketch)
|
||||
sketch.addConstraint(Sketcher.Constraint("Parallel", 0, 1))
|
||||
return lambda value: (
|
||||
(value.Geometry[0].EndPoint.x - value.Geometry[0].StartPoint.x)
|
||||
* (value.Geometry[1].EndPoint.y - value.Geometry[1].StartPoint.y)
|
||||
- (value.Geometry[0].EndPoint.y - value.Geometry[0].StartPoint.y)
|
||||
* (value.Geometry[1].EndPoint.x - value.Geometry[1].StartPoint.x)
|
||||
) / (line_length(value.Geometry[0]) * line_length(value.Geometry[1]))
|
||||
|
||||
|
||||
def setup_perpendicular(sketch):
|
||||
add_lines(sketch)
|
||||
sketch.addConstraint(Sketcher.Constraint("Perpendicular", 0, 1))
|
||||
return lambda value: (
|
||||
(value.Geometry[0].EndPoint.x - value.Geometry[0].StartPoint.x)
|
||||
* (value.Geometry[1].EndPoint.x - value.Geometry[1].StartPoint.x)
|
||||
+ (value.Geometry[0].EndPoint.y - value.Geometry[0].StartPoint.y)
|
||||
* (value.Geometry[1].EndPoint.y - value.Geometry[1].StartPoint.y)
|
||||
) / (line_length(value.Geometry[0]) * line_length(value.Geometry[1]))
|
||||
|
||||
|
||||
def setup_equal(sketch):
|
||||
add_lines(sketch)
|
||||
sketch.addConstraint(Sketcher.Constraint("Equal", 0, 1))
|
||||
return lambda value: line_length(value.Geometry[0]) - line_length(value.Geometry[1])
|
||||
|
||||
|
||||
def setup_tangent(sketch):
|
||||
sketch.addGeometry([circle(0, 0, 2), circle(6, 0, 1)], False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Tangent", 0, 1))
|
||||
return lambda value: distance(value.Geometry[0].Center, value.Geometry[1].Center) - value.Geometry[0].Radius - value.Geometry[1].Radius
|
||||
|
||||
|
||||
def setup_distance(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Distance", 0, 5.0))
|
||||
return lambda value: line_length(value.Geometry[0]) - 5.0
|
||||
|
||||
|
||||
def setup_distance_x(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, 3.0))
|
||||
return lambda value: value.Geometry[0].EndPoint.x - value.Geometry[0].StartPoint.x - 3.0
|
||||
|
||||
|
||||
def setup_distance_y(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("DistanceY", 0, 1, 0, 2, 4.0))
|
||||
return lambda value: value.Geometry[0].EndPoint.y - value.Geometry[0].StartPoint.y - 4.0
|
||||
|
||||
|
||||
def setup_angle(sketch):
|
||||
sketch.addGeometry(line(0, 0, 3, 3), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Angle", 0, math.pi / 4))
|
||||
return lambda value: math.atan2(
|
||||
value.Geometry[0].EndPoint.y - value.Geometry[0].StartPoint.y,
|
||||
value.Geometry[0].EndPoint.x - value.Geometry[0].StartPoint.x,
|
||||
) - math.pi / 4
|
||||
|
||||
|
||||
def setup_radius(sketch):
|
||||
sketch.addGeometry(circle(2, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Radius", 0, 4.0))
|
||||
return lambda value: value.Geometry[0].Radius - 4.0
|
||||
|
||||
|
||||
def setup_diameter(sketch):
|
||||
sketch.addGeometry(circle(2, 3, 4), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Diameter", 0, 8.0))
|
||||
return lambda value: 2 * value.Geometry[0].Radius - 8.0
|
||||
|
||||
|
||||
def setup_point_on_object(sketch):
|
||||
sketch.addGeometry([point(2, 3), line(0, 0, 4, 0)], False)
|
||||
sketch.addConstraint(Sketcher.Constraint("PointOnObject", 0, 1, 1))
|
||||
return lambda value: value.Geometry[0].Y - value.Geometry[1].StartPoint.y
|
||||
|
||||
|
||||
def setup_symmetric(sketch):
|
||||
sketch.addGeometry([point(1, 2), point(0, 0), point(4, 6)], False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Symmetric", 0, 1, 1, 1, 2, 1))
|
||||
return lambda value: math.hypot(
|
||||
(value.Geometry[0].X + value.Geometry[1].X) / 2 - value.Geometry[2].X,
|
||||
(value.Geometry[0].Y + value.Geometry[1].Y) / 2 - value.Geometry[2].Y,
|
||||
)
|
||||
|
||||
|
||||
def setup_internal_alignment(sketch):
|
||||
sketch.addGeometry(Part.Ellipse(App.Vector(2, 3, 0), 5, 3), False)
|
||||
sketch.exposeInternalGeometry(0)
|
||||
return lambda value: 0 if "InternalAlignment" in constraint_types(value) else 1
|
||||
|
||||
|
||||
def setup_snells_law(sketch):
|
||||
sketch.addGeometry([
|
||||
line(0, 0, -4, 4),
|
||||
line(0, 0, 4, 3),
|
||||
line(-5, 0, 5, 0),
|
||||
], False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, 1, 1))
|
||||
sketch.addConstraint(Sketcher.Constraint("PointOnObject", 0, 1, 2))
|
||||
sketch.addConstraint(Sketcher.Constraint("SnellsLaw", 0, 1, 1, 1, 2, 1.5))
|
||||
return lambda value: 0 if "SnellsLaw" in constraint_types(value) else 1
|
||||
|
||||
|
||||
def setup_block(sketch):
|
||||
sketch.addGeometry(line(1, 2, 4, 6), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Block", 0))
|
||||
return lambda value: distance(value.Geometry[0].StartPoint, App.Vector(1, 2, 0)) + distance(value.Geometry[0].EndPoint, App.Vector(4, 6, 0))
|
||||
|
||||
|
||||
def setup_weight(sketch):
|
||||
curve = Part.BSplineCurve()
|
||||
curve.buildFromPolesMultsKnots(
|
||||
[App.Vector(0, 0, 0), App.Vector(1, 2, 0), App.Vector(3, 2, 0), App.Vector(4, 0, 0)],
|
||||
[4, 4],
|
||||
[0, 1],
|
||||
False,
|
||||
3,
|
||||
[1, 0.75, 1.25, 1],
|
||||
)
|
||||
sketch.addGeometry(curve, False)
|
||||
sketch.exposeInternalGeometry(0)
|
||||
weight_index = next(index for index, constraint in enumerate(sketch.Constraints) if str(constraint.Type) == "Weight")
|
||||
sketch.setDatum(weight_index, App.Units.Quantity("1.5"))
|
||||
return lambda value: value.Geometry[0].getWeights()[0] - 1.5
|
||||
|
||||
|
||||
SUCCESS_SETUPS = [
|
||||
("coincident-success", "Coincident", setup_coincident),
|
||||
("horizontal-success", "Horizontal", setup_horizontal),
|
||||
("vertical-success", "Vertical", setup_vertical),
|
||||
("parallel-success", "Parallel", setup_parallel),
|
||||
("tangent-success", "Tangent", setup_tangent),
|
||||
("distance-success", "Distance", setup_distance),
|
||||
("distance-x-success", "DistanceX", setup_distance_x),
|
||||
("distance-y-success", "DistanceY", setup_distance_y),
|
||||
("angle-success", "Angle", setup_angle),
|
||||
("perpendicular-success", "Perpendicular", setup_perpendicular),
|
||||
("radius-success", "Radius", setup_radius),
|
||||
("equal-success", "Equal", setup_equal),
|
||||
("point-on-object-success", "PointOnObject", setup_point_on_object),
|
||||
("symmetric-success", "Symmetric", setup_symmetric),
|
||||
("internal-alignment-success", "InternalAlignment", setup_internal_alignment),
|
||||
("snells-law-success", "SnellsLaw", setup_snells_law),
|
||||
("block-success", "Block", setup_block),
|
||||
("diameter-success", "Diameter", setup_diameter),
|
||||
("weight-success", "Weight", setup_weight),
|
||||
]
|
||||
|
||||
|
||||
def base_failure_geometry(sketch):
|
||||
sketch.addGeometry([
|
||||
line(0, 0, 4, 0),
|
||||
line(0, 2, 3, 6),
|
||||
point(2, 3),
|
||||
circle(8, 3, 2),
|
||||
Part.Ellipse(App.Vector(2, 3, 0), 5, 3),
|
||||
], False)
|
||||
|
||||
|
||||
FAILURE_CONSTRAINTS = [
|
||||
("coincident-invalid-reference", "Coincident", lambda: Sketcher.Constraint("Coincident", 0, 2, 99, 1)),
|
||||
("horizontal-invalid-reference", "Horizontal", lambda: Sketcher.Constraint("Horizontal", 99)),
|
||||
("vertical-invalid-reference", "Vertical", lambda: Sketcher.Constraint("Vertical", 99)),
|
||||
("parallel-invalid-reference", "Parallel", lambda: Sketcher.Constraint("Parallel", 0, 99)),
|
||||
("tangent-invalid-reference", "Tangent", lambda: Sketcher.Constraint("Tangent", 3, 99)),
|
||||
("distance-invalid-reference", "Distance", lambda: Sketcher.Constraint("Distance", 99, 5.0)),
|
||||
("distance-x-invalid-reference", "DistanceX", lambda: Sketcher.Constraint("DistanceX", 99, 1, 0, 2, 3.0)),
|
||||
("distance-y-invalid-reference", "DistanceY", lambda: Sketcher.Constraint("DistanceY", 99, 1, 0, 2, 4.0)),
|
||||
("angle-invalid-reference", "Angle", lambda: Sketcher.Constraint("Angle", 99, math.pi / 4)),
|
||||
("perpendicular-invalid-reference", "Perpendicular", lambda: Sketcher.Constraint("Perpendicular", 0, 99)),
|
||||
("radius-invalid-reference", "Radius", lambda: Sketcher.Constraint("Radius", 99, 4.0)),
|
||||
("equal-invalid-reference", "Equal", lambda: Sketcher.Constraint("Equal", 0, 99)),
|
||||
("point-on-object-invalid-reference", "PointOnObject", lambda: Sketcher.Constraint("PointOnObject", 2, 1, 99)),
|
||||
("symmetric-invalid-reference", "Symmetric", lambda: Sketcher.Constraint("Symmetric", 2, 1, 2, 1, 99, 1)),
|
||||
("internal-alignment-invalid-reference", "InternalAlignment", lambda: Sketcher.Constraint("InternalAlignment:EllipseMajorDiameter", 99, 4)),
|
||||
("snells-law-invalid-reference", "SnellsLaw", lambda: Sketcher.Constraint("SnellsLaw", 0, 1, 1, 1, 99, 1.5)),
|
||||
("block-invalid-reference", "Block", lambda: Sketcher.Constraint("Block", 99)),
|
||||
("diameter-invalid-reference", "Diameter", lambda: Sketcher.Constraint("Diameter", 99, 8.0)),
|
||||
("weight-invalid-reference", "Weight", lambda: Sketcher.Constraint("Weight", 99, 1.5)),
|
||||
]
|
||||
|
||||
|
||||
def run_failure(case_id, constraint_type, constraint_factory):
|
||||
document, sketch = new_sketch(case_id)
|
||||
try:
|
||||
base_failure_geometry(sketch)
|
||||
add_result = None
|
||||
error = None
|
||||
try:
|
||||
add_result = int(sketch.addConstraint(constraint_factory()))
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
solve_status = None
|
||||
state = None
|
||||
if error is None:
|
||||
try:
|
||||
solve_status = int(sketch.solve())
|
||||
state = solver_state(sketch, solve_status)
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
failure_observed = (
|
||||
error is not None
|
||||
or add_result is None
|
||||
or add_result < 0
|
||||
or solve_status is None
|
||||
or solve_status < 0
|
||||
or bool(state and (state["conflicting"] or state["redundant"] or state["malformed"]))
|
||||
)
|
||||
return {
|
||||
"id": case_id,
|
||||
"constraintType": constraint_type,
|
||||
"expected": "failure",
|
||||
"observed": "failure" if failure_observed else "unexpected-success",
|
||||
"addResult": add_result,
|
||||
**(state or {}),
|
||||
**({"errorType": type(error).__name__, "error": str(error)} if error else {}),
|
||||
}
|
||||
finally:
|
||||
close_document(document)
|
||||
|
||||
|
||||
def run_classification_cases():
|
||||
cases = []
|
||||
|
||||
document, sketch = new_sketch("classification-reference")
|
||||
try:
|
||||
sketch.addGeometry(line(0, 0, 2, 1), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
|
||||
reference_index = sketch.addConstraint([Sketcher.Constraint("Distance", 0, 99.0)])[0]
|
||||
sketch.setDriving(reference_index, False)
|
||||
solve_status = sketch.solve()
|
||||
state = solver_state(sketch, solve_status)
|
||||
measured_value = float(sketch.Constraints[reference_index].Value)
|
||||
passed = state["solveStatus"] == 0 and state["degreesOfFreedom"] == 3 and not state["conflicting"] and not state["redundant"] and not state["malformed"] and sketch.Constraints[reference_index].Driving is False and math.isfinite(measured_value) and abs(measured_value - 99.0) > TOLERANCE
|
||||
cases.append({"id": "reference-dimension", "expected": "reference", "observed": "reference" if passed else "unexpected", "inputValue": 99.0, "measuredValue": measured_value, "driving": bool(sketch.Constraints[reference_index].Driving), **state})
|
||||
finally:
|
||||
close_document(document)
|
||||
|
||||
for case_id, second_value, expected_status, expected_redundant, expected_conflicting in [
|
||||
("redundant-dimension", 5.0, -2, [2], []),
|
||||
("conflicting-dimension", 8.0, -3, [], [1, 2]),
|
||||
]:
|
||||
document, sketch = new_sketch("classification-" + case_id)
|
||||
try:
|
||||
sketch.addGeometry(line(0, 0, 1, 0), False)
|
||||
sketch.addConstraint(Sketcher.Constraint("Distance", 0, 5.0))
|
||||
sketch.addConstraint(Sketcher.Constraint("Distance", 0, second_value))
|
||||
solve_status = sketch.solve()
|
||||
state = solver_state(sketch, solve_status)
|
||||
passed = state["solveStatus"] == expected_status and state["degreesOfFreedom"] == 3 and state["redundant"] == expected_redundant and state["conflicting"] == expected_conflicting and not state["malformed"]
|
||||
expected = "redundant" if expected_redundant else "conflicting"
|
||||
cases.append({"id": case_id, "expected": expected, "observed": expected if passed else "unexpected", "values": [5.0, second_value], **state})
|
||||
finally:
|
||||
close_document(document)
|
||||
return cases
|
||||
|
||||
|
||||
success_cases = [run_success(*fixture) for fixture in SUCCESS_SETUPS]
|
||||
failure_cases = [run_failure(*fixture) for fixture in FAILURE_CONSTRAINTS]
|
||||
classification_cases = run_classification_cases()
|
||||
version = App.Version()
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"baselineId": "freecad-1.1.1-sketcher-constraint-oracle",
|
||||
"freecadVersion": ".".join(version[:3]),
|
||||
"revision": str(version[3]),
|
||||
"gitCommit": str(version[7]) if len(version) > 7 else "",
|
||||
"tolerance": TOLERANCE,
|
||||
"status": "pass" if all(case["observed"] == case["expected"] for case in success_cases + failure_cases + classification_cases) else "failed",
|
||||
"summary": {
|
||||
"constraintTypes": len(SUCCESS_SETUPS),
|
||||
"successCases": len(success_cases),
|
||||
"successPassed": sum(case["observed"] == "success" for case in success_cases),
|
||||
"failureCases": len(failure_cases),
|
||||
"failurePassed": sum(case["observed"] == "failure" for case in failure_cases),
|
||||
"classificationCases": len(classification_cases),
|
||||
"classificationPassed": sum(case["observed"] == case["expected"] for case in classification_cases),
|
||||
},
|
||||
"successCases": success_cases,
|
||||
"failureCases": failure_cases,
|
||||
"classificationCases": classification_cases,
|
||||
}
|
||||
print("FREECAD_SKETCHER_ORACLE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
|
||||
sys.stdout.flush()
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user