424 lines
16 KiB
Python
424 lines
16 KiB
Python
import json
|
|
import struct
|
|
import unittest
|
|
from array import array
|
|
from pathlib import Path
|
|
|
|
import build123d
|
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
from OCP.TopAbs import TopAbs_FACE
|
|
from OCP.TopExp import TopExp_Explorer
|
|
from OCP.TopoDS import TopoDS
|
|
|
|
from cadpy.glb import (
|
|
export_assembly_glb_from_scene,
|
|
export_native_glb_from_scene,
|
|
export_part_glb_from_scene,
|
|
read_step_display_edge_manifest_from_glb,
|
|
read_step_topology_bundle_from_glb,
|
|
read_step_topology_manifest_from_glb,
|
|
)
|
|
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION, glb_surface_edge_class_has_nonzero_values
|
|
from cadpy.step_scene import (
|
|
LoadedStepScene,
|
|
OccurrenceNode,
|
|
SelectorBundle,
|
|
SelectorOptions,
|
|
SelectorProfile,
|
|
_shape_hash,
|
|
extract_selectors_from_scene,
|
|
load_step_scene,
|
|
)
|
|
from tests.python.support.tmp_root import temporary_directory
|
|
|
|
|
|
def _identity_transform() -> tuple[float, ...]:
|
|
return (
|
|
1.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
)
|
|
|
|
|
|
def _meshed_box(width: float = 10, depth: float = 20, height: float = 30) -> object:
|
|
shape = build123d.Box(width, depth, height).wrapped
|
|
BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform()
|
|
return shape
|
|
|
|
|
|
def _single_leaf_scene(shape: object, **kwargs) -> tuple[LoadedStepScene, int]:
|
|
prototype_key = _shape_hash(shape)
|
|
scene = LoadedStepScene(
|
|
step_path=Path("fixture.step"),
|
|
roots=[
|
|
OccurrenceNode(
|
|
path=(1,),
|
|
name="fixture",
|
|
source_name="fixture",
|
|
transform=_identity_transform(),
|
|
local_transform=_identity_transform(),
|
|
prototype_key=prototype_key,
|
|
)
|
|
],
|
|
prototype_shapes={prototype_key: shape},
|
|
prototype_names={prototype_key: "fixture"},
|
|
**kwargs,
|
|
)
|
|
return scene, prototype_key
|
|
|
|
|
|
def _read_glb_json(path: Path) -> dict[str, object]:
|
|
payload = path.read_bytes()
|
|
magic, version, _length = struct.unpack_from("<III", payload, 0)
|
|
if magic != 0x46546C67 or version != 2:
|
|
raise AssertionError("Not a GLB v2 file")
|
|
chunk_length, chunk_type = struct.unpack_from("<I4s", payload, 12)
|
|
if chunk_type != b"JSON":
|
|
raise AssertionError("First GLB chunk is not JSON")
|
|
return json.loads(payload[20:20 + chunk_length].decode("utf-8"))
|
|
|
|
|
|
class GlbExportTests(unittest.TestCase):
|
|
def test_part_glb_embeds_step_topology_extension(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
scene, _prototype_key = _single_leaf_scene(_meshed_box())
|
|
bundle = SelectorBundle(
|
|
manifest={
|
|
"schemaVersion": 1,
|
|
"profile": "artifact",
|
|
"stepPath": "fixture.step",
|
|
"stepHash": "step-hash-123",
|
|
"tables": {},
|
|
"occurrences": [],
|
|
"shapes": [],
|
|
"faces": [],
|
|
"edges": [],
|
|
},
|
|
buffers={"edgeIds": array("I", [7, 11]), "surfaceHalfEdges": array("I")},
|
|
)
|
|
|
|
glb_path = export_part_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
selector_bundle=bundle,
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
self.assertIn("STEP_topology", gltf.get("extensionsUsed", []))
|
|
self.assertIn("STEP_topology", gltf.get("extensions", {}))
|
|
extension = gltf.get("extensions", {}).get("STEP_topology", {})
|
|
self.assertIn("indexView", extension)
|
|
self.assertIn("edgeView", extension)
|
|
self.assertIn("selectorView", extension)
|
|
index = read_step_topology_manifest_from_glb(glb_path)
|
|
self.assertIsNotNone(index)
|
|
assert index is not None
|
|
self.assertEqual("index", index.get("profile"))
|
|
self.assertEqual("part", index.get("entryKind"))
|
|
self.assertNotIn("cadRef", index)
|
|
self.assertNotIn("cadPath", index)
|
|
surface_edges = read_step_display_edge_manifest_from_glb(glb_path)
|
|
self.assertIsNotNone(surface_edges)
|
|
assert surface_edges is not None
|
|
self.assertEqual("surface-edges", surface_edges.get("profile"))
|
|
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, surface_edges.get("schemaVersion"))
|
|
self.assertEqual("surfaceHalfEdges", surface_edges.get("halfEdgesView"))
|
|
self.assertIn("surfaceHalfEdges", surface_edges.get("buffers", {}).get("views", {}))
|
|
self.assertNotIn("faces", surface_edges)
|
|
self.assertNotIn("relations", surface_edges)
|
|
embedded = read_step_topology_bundle_from_glb(glb_path)
|
|
self.assertIsNotNone(embedded)
|
|
assert embedded is not None
|
|
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, embedded.manifest["schemaVersion"])
|
|
self.assertNotIn("cadRef", embedded.manifest)
|
|
self.assertNotIn("cadPath", embedded.manifest)
|
|
self.assertEqual([7, 11], list(embedded.buffers["edgeIds"]))
|
|
|
|
def test_step_glb_surface_edge_class_attribute_is_populated_from_selector_topology(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-surface-edges-") as temp_dir:
|
|
step_path = Path(temp_dir) / "cylinder.step"
|
|
build123d.export_step(build123d.Cylinder(5, 10), step_path)
|
|
scene = load_step_scene(step_path)
|
|
bundle = extract_selectors_from_scene(
|
|
scene,
|
|
profile=SelectorProfile.ARTIFACT,
|
|
options=SelectorOptions(linear_deflection=0.2, angular_deflection=0.2),
|
|
)
|
|
|
|
glb_path = export_part_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
linear_deflection=0.2,
|
|
angular_deflection=0.2,
|
|
selector_bundle=bundle,
|
|
)
|
|
|
|
self.assertTrue(glb_surface_edge_class_has_nonzero_values(glb_path))
|
|
|
|
def test_legacy_topology_json_and_bin_bundle_is_read_when_glb_lacks_extension(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
artifact_dir = Path(temp_dir) / ".fixture.step"
|
|
artifact_dir.mkdir()
|
|
glb_path = artifact_dir / "model.glb"
|
|
glb_path.write_bytes(b"legacy visual glb without embedded topology")
|
|
|
|
edge_ids = array("I", [19, 23])
|
|
edge_bytes = edge_ids.tobytes()
|
|
(artifact_dir / "topology.bin").write_bytes(edge_bytes)
|
|
(artifact_dir / "topology.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"schemaVersion": 1,
|
|
"profile": "artifact",
|
|
"cadRef": "fixtures/legacy",
|
|
"buffers": {
|
|
"uri": "topology.bin",
|
|
"littleEndian": True,
|
|
"views": {
|
|
"edgeIds": {
|
|
"dtype": "uint32",
|
|
"byteOffset": 0,
|
|
"byteLength": len(edge_bytes),
|
|
"count": 2,
|
|
"itemSize": 4,
|
|
}
|
|
},
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
index = read_step_topology_manifest_from_glb(glb_path)
|
|
self.assertIsNotNone(index)
|
|
assert index is not None
|
|
self.assertEqual("fixtures/legacy", index.get("cadRef"))
|
|
|
|
bundle = read_step_topology_bundle_from_glb(glb_path)
|
|
self.assertIsNotNone(bundle)
|
|
assert bundle is not None
|
|
self.assertEqual("fixtures/legacy", bundle.manifest.get("cadRef"))
|
|
self.assertEqual([19, 23], list(bundle.buffers["edgeIds"]))
|
|
|
|
def test_assembly_glb_preserves_face_colors_as_material_primitives(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
shape = _meshed_box()
|
|
face_hashes: list[int] = []
|
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
|
while explorer.More() and len(face_hashes) < 2:
|
|
face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
|
|
explorer.Next()
|
|
scene, prototype_key = _single_leaf_scene(
|
|
shape,
|
|
prototype_face_colors={
|
|
_shape_hash(shape): {
|
|
face_hashes[0]: (1.0, 0.0, 0.0, 1.0),
|
|
face_hashes[1]: (0.0, 0.0, 1.0, 1.0),
|
|
}
|
|
},
|
|
)
|
|
|
|
glb_path = export_assembly_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
colors = [
|
|
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
|
|
for material in gltf.get("materials", [])
|
|
]
|
|
primitives = gltf.get("meshes", [])[0].get("primitives", [])
|
|
primitive_materials = {primitive.get("material") for primitive in primitives}
|
|
primitive_attributes = [primitive.get("attributes", {}) for primitive in primitives]
|
|
self.assertEqual(prototype_key, _shape_hash(shape))
|
|
self.assertIn([1.0, 0.0, 0.0, 1.0], colors)
|
|
self.assertIn([0.0, 0.0, 1.0, 1.0], colors)
|
|
self.assertGreaterEqual(len(primitive_materials), 2)
|
|
self.assertTrue(all("NORMAL" in attributes for attributes in primitive_attributes))
|
|
|
|
def test_assembly_glb_applies_occurrence_color_to_descendant_meshes(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
shape = _meshed_box()
|
|
prototype_key = _shape_hash(shape)
|
|
scene = LoadedStepScene(
|
|
step_path=step_path,
|
|
roots=[
|
|
OccurrenceNode(
|
|
path=(1,),
|
|
name="assembly",
|
|
source_name="assembly",
|
|
transform=_identity_transform(),
|
|
prototype_key=None,
|
|
local_transform=_identity_transform(),
|
|
children=(
|
|
OccurrenceNode(
|
|
path=(1, 1),
|
|
name="part",
|
|
source_name="part",
|
|
transform=_identity_transform(),
|
|
local_transform=_identity_transform(),
|
|
prototype_key=prototype_key,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
prototype_shapes={prototype_key: shape},
|
|
prototype_names={prototype_key: "fixture"},
|
|
)
|
|
|
|
glb_path = export_assembly_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
occurrence_colors={"o1": (0.1, 0.2, 0.3, 1.0)},
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
colors = [
|
|
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
|
|
for material in gltf.get("materials", [])
|
|
]
|
|
self.assertIn([0.1, 0.2, 0.3, 1.0], colors)
|
|
|
|
def test_part_glb_uses_scene_material_colors(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
shape = _meshed_box()
|
|
scene, prototype_key = _single_leaf_scene(
|
|
shape,
|
|
prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
|
|
)
|
|
|
|
glb_path = export_part_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
colors = [
|
|
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
|
|
for material in gltf.get("materials", [])
|
|
]
|
|
self.assertEqual(prototype_key, _shape_hash(shape))
|
|
self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)
|
|
|
|
def test_native_glb_is_y_up_without_step_topology_or_cad_extras(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
shape = _meshed_box(width=10, depth=20, height=30)
|
|
scene, _prototype_key = _single_leaf_scene(
|
|
shape,
|
|
prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
|
|
)
|
|
|
|
glb_path = export_native_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
target_path=Path(temp_dir) / "fixture.glb",
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
self.assertNotIn("extensionsUsed", gltf)
|
|
self.assertNotIn("extensions", gltf)
|
|
self.assertTrue(all("extras" not in node for node in gltf.get("nodes", [])))
|
|
position_accessor = gltf["accessors"][0]
|
|
self.assertEqual([-0.005, -0.015, -0.01], [round(value, 6) for value in position_accessor["min"]])
|
|
self.assertEqual([0.005, 0.015, 0.01], [round(value, 6) for value in position_accessor["max"]])
|
|
colors = [
|
|
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
|
|
for material in gltf.get("materials", [])
|
|
]
|
|
self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)
|
|
|
|
def test_native_glb_converts_assembly_transforms_to_y_up(self) -> None:
|
|
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
|
|
step_path = Path(temp_dir) / "fixture.step"
|
|
shape = _meshed_box()
|
|
prototype_key = _shape_hash(shape)
|
|
translated = (
|
|
1.0,
|
|
0.0,
|
|
0.0,
|
|
10.0,
|
|
0.0,
|
|
1.0,
|
|
0.0,
|
|
20.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
30.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
1.0,
|
|
)
|
|
scene = LoadedStepScene(
|
|
step_path=step_path,
|
|
roots=[
|
|
OccurrenceNode(
|
|
path=(1,),
|
|
name="assembly",
|
|
source_name="assembly",
|
|
transform=_identity_transform(),
|
|
local_transform=_identity_transform(),
|
|
prototype_key=None,
|
|
children=(
|
|
OccurrenceNode(
|
|
path=(1, 1),
|
|
name="part",
|
|
source_name="part",
|
|
transform=translated,
|
|
local_transform=translated,
|
|
prototype_key=prototype_key,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
prototype_shapes={prototype_key: shape},
|
|
prototype_names={prototype_key: "fixture"},
|
|
)
|
|
|
|
glb_path = export_native_glb_from_scene(
|
|
step_path,
|
|
scene,
|
|
target_path=Path(temp_dir) / "assembly.glb",
|
|
linear_deflection=0.1,
|
|
angular_deflection=0.1,
|
|
)
|
|
|
|
gltf = _read_glb_json(glb_path)
|
|
child = next(node for node in gltf["nodes"] if node.get("mesh") == 0)
|
|
self.assertEqual("part", child.get("name"))
|
|
self.assertEqual([0.01, 0.03, -0.02], [round(value, 6) for value in child["matrix"][12:15]])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|