feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
"""Phase A characterization of the current Model 2.0 and OCP boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.BRepTools import BRepTools
|
||||
from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.core import Compound, Edge, Face
|
||||
from simplecadapi.kernel.ocp_export import make_compound_always
|
||||
from simplecadapi.operations import _make_geo_selector
|
||||
from simplecadapi.scene import (
|
||||
canonical_json_bytes,
|
||||
load_contract_artifact,
|
||||
profile_cross,
|
||||
profile_f32_bits,
|
||||
profile_normalize,
|
||||
)
|
||||
from simplecadapi.serializer import (
|
||||
_candidate_shapes_for_geo_selection,
|
||||
_geo_selector_score,
|
||||
_resolve_shape_from_geo_selector,
|
||||
)
|
||||
|
||||
|
||||
CHARACTERIZED_RENDER_PROFILE_CASES = {
|
||||
"bounded_canonicalization_failure",
|
||||
"closed_edge",
|
||||
"degenerate_edge",
|
||||
"duplicate_geometry_with_different_metadata",
|
||||
"duplicate_geometry_with_different_provenance",
|
||||
"negative_zero",
|
||||
"normal_fallback",
|
||||
"reversed_kernel_traversal",
|
||||
"shared_face_rejection",
|
||||
"symmetric_entity",
|
||||
}
|
||||
|
||||
|
||||
def _evaluated_profile() -> dict[str, object]:
|
||||
return json.loads(
|
||||
load_contract_artifact(
|
||||
"profiles/ocp-evaluated-properties-1.profile.json"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render_profile() -> dict[str, object]:
|
||||
return json.loads(
|
||||
load_contract_artifact("profiles/scene-1.0-ocp-glb-2.profile.json")
|
||||
)
|
||||
|
||||
|
||||
def _fallback_normal(face: Face) -> tuple[float, float, float]:
|
||||
BRepTools.Clean_s(face.wrapped, False)
|
||||
mesher = BRepMesh_IncrementalMesh(face.wrapped, 0.35, False, 0.22, False)
|
||||
mesher.Perform()
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face.wrapped, location, 0)
|
||||
assert triangulation is not None
|
||||
assert triangulation.HasNormals() is False
|
||||
indices = list(triangulation.Triangle(1).Get())
|
||||
if face.wrapped.Orientation() == TopAbs_REVERSED:
|
||||
indices = [indices[0], indices[2], indices[1]]
|
||||
points = []
|
||||
transform = location.Transformation()
|
||||
for index in indices:
|
||||
point = triangulation.Node(index).Transformed(transform)
|
||||
points.append((point.X() / 1000, point.Z() / 1000, -point.Y() / 1000))
|
||||
first = tuple(points[1][index] - points[0][index] for index in range(3))
|
||||
second = tuple(points[2][index] - points[0][index] for index in range(3))
|
||||
return profile_normalize(profile_cross(first, second))
|
||||
|
||||
|
||||
def test_all_render_profile_characterization_cases_have_targeted_evidence():
|
||||
profile = _render_profile()
|
||||
assert set(profile["rules"]["required_characterization_cases"]) == (
|
||||
CHARACTERIZED_RENDER_PROFILE_CASES
|
||||
)
|
||||
|
||||
|
||||
def test_model_schema_2_topology_witnesses_and_roles_survive_clean_replay():
|
||||
with scad.GraphSession(graph_id="scene-characterization") as session:
|
||||
profile = scad.make_rectangle_rface(width=5, height=3)
|
||||
scad.extrude_rsolid(
|
||||
profile=profile,
|
||||
direction=(0, 0, 1),
|
||||
distance=2,
|
||||
start_face_tag="role.start",
|
||||
end_face_tag="role.end",
|
||||
)
|
||||
|
||||
model_json = scad.export_model_json(session=session)
|
||||
payload = json.loads(model_json)
|
||||
assert payload["schema_version"] == "2.0"
|
||||
assert payload["graph"]["schema_version"] == "2.0"
|
||||
assert {
|
||||
key: payload["graph"]["capabilities"][key]
|
||||
for key in (
|
||||
"topology_delta_entries",
|
||||
"durable_topology_parent_refs",
|
||||
"operation_output_roles",
|
||||
)
|
||||
} == {
|
||||
"topology_delta_entries": True,
|
||||
"durable_topology_parent_refs": True,
|
||||
"operation_output_roles": True,
|
||||
}
|
||||
|
||||
assert len(payload["topology_delta_log"]) == 1
|
||||
witness = payload["topology_delta_log"][0]
|
||||
delta = witness["delta"]
|
||||
assert sorted({entry["role"] for entry in delta["roles"]}) == [
|
||||
"extrusion.end",
|
||||
"extrusion.side",
|
||||
"extrusion.start",
|
||||
]
|
||||
assert all(
|
||||
entry["ref"]["graph_id"] == "scene-characterization"
|
||||
and entry["ref"]["node_id"] == witness["node_id"]
|
||||
for entry in delta["entries"]
|
||||
)
|
||||
|
||||
replayed = scad.replay_model_json(json_str=model_json)
|
||||
assert len(replayed) == 1
|
||||
solid = replayed[0]
|
||||
assert {
|
||||
role: sum(scad.ql.output_role(role)(face) for face in solid.get_faces())
|
||||
for role in ("extrusion.start", "extrusion.end", "extrusion.side")
|
||||
} == {
|
||||
"extrusion.start": 1,
|
||||
"extrusion.end": 1,
|
||||
"extrusion.side": 4,
|
||||
}
|
||||
|
||||
|
||||
def test_profile_forces_reversed_edges_forward_before_selector_evaluation():
|
||||
profile = _evaluated_profile()
|
||||
preparation = profile["rules"]["shape_preparation"]
|
||||
assert preparation["edge_orientation_call"] == "edge.Oriented(TopAbs_FORWARD)"
|
||||
assert preparation["edge_orientation_order"] == "force_forward_before_location_bake"
|
||||
|
||||
edge = scad.make_line_redge(start=(0, 0, 0), end=(3, 4, 0))
|
||||
reversed_edge = Edge(edge.wrapped.Oriented(TopAbs_REVERSED))
|
||||
forward_edge = Edge(reversed_edge.wrapped.Oriented(TopAbs_FORWARD))
|
||||
|
||||
assert edge.wrapped.Orientation() == TopAbs_FORWARD
|
||||
assert reversed_edge.wrapped.Orientation() == TopAbs_REVERSED
|
||||
assert forward_edge.wrapped.Orientation() == TopAbs_FORWARD
|
||||
assert _make_geo_selector(forward_edge) == _make_geo_selector(edge)
|
||||
assert _geo_selector_score(
|
||||
forward_edge, _make_geo_selector(edge)
|
||||
) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_reversed_kernel_traversal_preserves_normalized_evaluated_properties():
|
||||
first = scad.make_box_rsolid(
|
||||
width=1,
|
||||
height=2,
|
||||
depth=3,
|
||||
bottom_face_center=(-5, 0, 0),
|
||||
)
|
||||
second = scad.make_box_rsolid(
|
||||
width=2,
|
||||
height=3,
|
||||
depth=4,
|
||||
bottom_face_center=(5, 0, 0),
|
||||
)
|
||||
forward_root = Compound(
|
||||
make_compound_always([first.wrapped, second.wrapped])
|
||||
)
|
||||
reversed_root = Compound(
|
||||
make_compound_always([second.wrapped, first.wrapped])
|
||||
)
|
||||
forward_solids = forward_root.get_solids()
|
||||
reversed_solids = reversed_root.get_solids()
|
||||
|
||||
assert len(forward_solids) == len(reversed_solids) == 2
|
||||
assert forward_solids[0].wrapped.IsSame(first.wrapped)
|
||||
assert forward_solids[1].wrapped.IsSame(second.wrapped)
|
||||
assert reversed_solids[0].wrapped.IsSame(second.wrapped)
|
||||
assert reversed_solids[1].wrapped.IsSame(first.wrapped)
|
||||
|
||||
def evaluated_property_records(root: Compound) -> list[bytes]:
|
||||
records = []
|
||||
for solid in root.get_solids():
|
||||
selector = _make_geo_selector(solid)
|
||||
records.append(
|
||||
canonical_json_bytes(
|
||||
{
|
||||
"bounds": selector["bbox"],
|
||||
"kind": selector["kind"],
|
||||
"volume": selector["volume"],
|
||||
}
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
forward_records = evaluated_property_records(forward_root)
|
||||
reversed_records = evaluated_property_records(reversed_root)
|
||||
assert forward_records != reversed_records
|
||||
assert tuple(sorted(forward_records)) == tuple(sorted(reversed_records))
|
||||
|
||||
|
||||
def test_symmetric_entities_are_selector_ambiguous_even_if_legacy_resolver_picks_one():
|
||||
profile = _evaluated_profile()
|
||||
selector_rules = profile["rules"]["geo_exact_selector"]
|
||||
threshold = selector_rules["threshold"]
|
||||
assert selector_rules["multiple_match_status"] == "selector_ambiguous"
|
||||
|
||||
first = scad.make_box_rsolid(width=2, height=2, depth=2)
|
||||
second = scad.make_box_rsolid(width=2, height=2, depth=2)
|
||||
compound = Compound(make_compound_always([first.wrapped, second.wrapped]))
|
||||
selector = _make_geo_selector(compound.get_edges()[0])
|
||||
candidates = _candidate_shapes_for_geo_selection(compound, "edge")
|
||||
passing = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if _geo_selector_score(candidate, selector) <= threshold
|
||||
]
|
||||
|
||||
assert len(passing) == 2
|
||||
legacy_result = _resolve_shape_from_geo_selector(compound, selector)
|
||||
assert any(legacy_result.same_topology(candidate) for candidate in passing)
|
||||
|
||||
|
||||
def test_serialized_closed_edge_connector_replays_but_has_no_current_frame():
|
||||
profile = _evaluated_profile()
|
||||
assert (
|
||||
profile["rules"]["connector_frame"]["edge_undefined"]
|
||||
== "missing_endpoint_or_coincident_endpoints"
|
||||
)
|
||||
|
||||
with scad.GraphSession(graph_id="closed-edge-characterization") as session:
|
||||
edge = scad.make_circle_redge(center=(0, 0, 0), radius=2)
|
||||
scad.make_edge_connector_rconnector(
|
||||
connector_id="closed_edge",
|
||||
edge=edge,
|
||||
)
|
||||
|
||||
replayed = scad.replay_model_json(
|
||||
json_str=scad.export_model_json(session=session)
|
||||
)
|
||||
assert len(replayed) == 1
|
||||
connector = replayed[0]
|
||||
selector = connector.geometry_ref.geo_selector
|
||||
assert selector["start"] == selector["end"]
|
||||
with pytest.raises(ValueError, match="direction must be a non-zero vector"):
|
||||
_ = connector.placement
|
||||
|
||||
|
||||
def test_profile_canonicalizes_negative_zero_before_render_keys():
|
||||
profile = _render_profile()
|
||||
assert profile["rules"]["numeric"]["negative_zero"] == (
|
||||
"canonicalize_to_positive_zero"
|
||||
)
|
||||
assert profile_f32_bits(-0.0) == profile_f32_bits(0.0) == 0
|
||||
|
||||
|
||||
def test_short_valid_ocp_edge_collapses_after_gltf_float32_conversion():
|
||||
profile = _render_profile()
|
||||
assert profile["rules"]["canonical_blocks"]["edge_empty_policy"] == (
|
||||
"retain_degenerate_entity_without_render_block"
|
||||
)
|
||||
edge = scad.make_line_redge(
|
||||
start=(1000, 0, 0),
|
||||
end=(1000.00001, 0, 0),
|
||||
)
|
||||
start = edge.get_start_vertex().get_coordinates()
|
||||
end = edge.get_end_vertex().get_coordinates()
|
||||
|
||||
assert edge.get_length() > 0
|
||||
assert tuple(profile_f32_bits(component / 1000) for component in start) == tuple(
|
||||
profile_f32_bits(component / 1000) for component in end
|
||||
)
|
||||
|
||||
|
||||
def test_missing_kernel_normals_use_oriented_triangle_fallback():
|
||||
profile = _render_profile()
|
||||
assert profile["rules"]["normal"]["fallback"] == (
|
||||
"oriented_triangle_cross_product_after_coordinate_conversion"
|
||||
)
|
||||
face = scad.make_rectangle_rface(width=2, height=3)
|
||||
reversed_face = Face(face.wrapped.Oriented(TopAbs_REVERSED))
|
||||
|
||||
assert _fallback_normal(face) == pytest.approx((0, 1, 0))
|
||||
assert _fallback_normal(reversed_face) == pytest.approx((0, -1, 0))
|
||||
|
||||
|
||||
def test_duplicate_geometry_metadata_changes_selector_bytes_but_not_score():
|
||||
first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
|
||||
second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
|
||||
first.set_metadata("geo", {"label": "first"})
|
||||
second.set_metadata("geo", {"label": "second"})
|
||||
first_selector = _make_geo_selector(first)
|
||||
second_selector = _make_geo_selector(second)
|
||||
|
||||
assert first_selector["metadata_geo"] != second_selector["metadata_geo"]
|
||||
assert _geo_selector_score(first, second_selector) == pytest.approx(0.0)
|
||||
assert _geo_selector_score(second, first_selector) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_duplicate_geometry_provenance_is_distinct_but_not_selector_scored():
|
||||
with scad.GraphSession(graph_id="duplicate-provenance"):
|
||||
first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
|
||||
second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
|
||||
|
||||
first_node = first._get_runtime("graph.node")
|
||||
second_node = second._get_runtime("graph.node")
|
||||
assert first_node.node_id != second_node.node_id
|
||||
assert _make_geo_selector(first) == _make_geo_selector(second)
|
||||
assert _geo_selector_score(first, _make_geo_selector(second)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_shared_shape_compound_violates_disjoint_solid_ownership_target():
|
||||
profile = _evaluated_profile()
|
||||
topology = profile["rules"]["topology"]
|
||||
assert topology["accepted_root"] == (
|
||||
"single_manifold_solid_or_compound_of_disjoint_manifold_solids"
|
||||
)
|
||||
assert "shared_face_between_solids" in topology["rejected_roots"]
|
||||
|
||||
solid = scad.make_box_rsolid(width=2, height=2, depth=2)
|
||||
compound = Compound(make_compound_always([solid.wrapped, solid.wrapped]))
|
||||
solids = compound.get_solids()
|
||||
assert len(solids) == 2
|
||||
assert solids[0].wrapped.IsSame(solids[1].wrapped)
|
||||
assert len(compound.get_faces()) == 12
|
||||
assert len({face.topo_id for face in compound.get_faces()}) == 6
|
||||
|
||||
|
||||
def test_symmetric_graph_canonicalization_has_a_hard_failure_budget():
|
||||
profile = _evaluated_profile()
|
||||
labeling = profile["rules"]["canonical_labeling"]
|
||||
assert labeling["maximum_states"] == 1_000_000
|
||||
assert labeling["budget_error"] == "entity_canonicalization_budget_exceeded"
|
||||
assert labeling["exact_candidate_ties"] == (
|
||||
"arbitrary_order_but_evaluate_every_branch"
|
||||
)
|
||||
Reference in New Issue
Block a user