346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""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"
|
|
)
|