From 7e98040ed55f7b399866574804b9ef00ef31f226 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 26 Jul 2026 22:07:16 -0500 Subject: [PATCH 1/2] Phase 6: pycvc.load_model binding + native (VTK-free) add_gltf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds cvc::model into pycvc and switches the scene helper's glTF loader off VTK's vtkGLTFReader to libcvc's native Assimp path (completes the native mesh/image loading arc, Phases 1-6). - pycvc_model.i (new): pycvc.load_model(path) -> pycvc.model. Mirrors pycvc_image.i's direct-wrap style. model.meshes / model.materials are %template'd std::vector proxies exposed as properties; the nested model::mesh is lifted with %feature(flatnested) so meshes[i].geometry()/ .material/.name work. material.base_color()/emissive() return tuples and base_color_texture() a pycvc.image (boost::array + by-value image members are %ignore'd and re-exposed via distinct-named %extend aliased back to the natural name — the same-name-%extend-swallowed-by-%ignore gotcha). model.merged() -> geometry, model.extents() -> 6-tuple. load_model needs no app (read_model returns bare value types; read-only accessors tolerate a null context). - pycvc.i: %include pycvc_model.i after image/geometry are wrapped. - CMakeLists.txt: wire pycvc_model.i into pycvc.i's DEPENDS + register test_pycvc_model.py. - pymod_gl/scenes.py: add_gltf now loads via pycvc.load_model(path).merged() (one flattened single-color geometry, matching the previous look) and adds through the native scene path (add_child_geometry when parented, else addGraphics), setting the uniform material via the GeometryNode setters (setUseSingleColor/setColor/setOpacity/setAmbient/setDiffuse/setSpecular, all public + %include'd in pycvc_gl.i). Imports NO vtkGLTFReader. building_occupancy still rasterizes via VTK (separate offscreen concern). Verified: pycvc builds; test_pycvc_model 1/1 (assimp loads an OBJ+MTL+PNG -> mesh/UVs/material/texture, merged() flatten, bad-extension raise); 11 other core pycvc tests pass. add_gltf can't run without a live GL scene; smoke- checked (AST: no vtk import; every setter it calls is public in GeometryNode.h + %include'd). black -l100 + ruff clean. --- bindings/pycvc/CMakeLists.txt | 4 +- bindings/pycvc/pycvc.i | 7 ++ bindings/pycvc/pycvc_model.i | 123 ++++++++++++++++++++ bindings/pycvc/pymod_gl/scenes.py | 91 ++++++++------- bindings/pycvc/test_pycvc_model.py | 180 +++++++++++++++++++++++++++++ 5 files changed, 363 insertions(+), 42 deletions(-) create mode 100644 bindings/pycvc/pycvc_model.i create mode 100644 bindings/pycvc/test_pycvc_model.py diff --git a/bindings/pycvc/CMakeLists.txt b/bindings/pycvc/CMakeLists.txt index 1db09d2..8d43279 100644 --- a/bindings/pycvc/CMakeLists.txt +++ b/bindings/pycvc/CMakeLists.txt @@ -45,7 +45,8 @@ set_property(SOURCE pycvc.i PROPERTY DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_algorithm.h ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_state.h ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_exec.h - ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_image.i) + ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_image.i + ${CMAKE_CURRENT_SOURCE_DIR}/pycvc_model.i) # Shared by both modules — defined before either option block so the scene # module can install/test even when the core module isn't built in this run. @@ -79,6 +80,7 @@ if(CVC_BUILD_PYCVC_CORE) pycvc_app:test_pycvc_app.py pycvc_filters:test_pycvc_filters.py pycvc_algorithm:test_pycvc_algorithm.py pycvc_image:test_pycvc_image.py + pycvc_model:test_pycvc_model.py pycvc_state:test_pycvc_state.py pycvc_async:test_pycvc_async.py pycvc_exec:test_pycvc_exec.py diff --git a/bindings/pycvc/pycvc.i b/bindings/pycvc/pycvc.i index a3be2fe..82b71be 100644 --- a/bindings/pycvc/pycvc.i +++ b/bindings/pycvc/pycvc.i @@ -731,6 +731,13 @@ namespace cvc { // machinery + capsule dtor defined above. %include "pycvc_image.i" +// ── Phase 3 (Phase-6 binding): cvc::model + pycvc.load_model ───────────── +// The multi-mesh scene value type (meshes + materials + textures) and the native +// loader pycvc.load_model(path) → pycvc.model. %include'd HERE, after geometry +// AND image, because model::mesh holds a cvc::geometry and material holds a +// cvc::image — both must already be wrapped for model's accessors to marshal. +%include "pycvc_model.i" + // ── Phase 2: compute layer (SDF / meshing / quality / generators) ─────── // Module-level free functions + enum constants + QualityStats, taking/returning // the real wrapped cvc::geometry/cvc::volume (declared above). Comes last so diff --git a/bindings/pycvc/pycvc_model.i b/bindings/pycvc/pycvc_model.i new file mode 100644 index 0000000..28b5a8e --- /dev/null +++ b/bindings/pycvc/pycvc_model.i @@ -0,0 +1,123 @@ +// pycvc_model.i — SWIG surface for cvc::model (Phase-3 mesh/model value type). +// +// NOT a standalone module: this file is %include'd by pycvc.i (AFTER geometry and +// image are defined, since model::mesh holds a cvc::geometry and material holds a +// cvc::image) so `model` / `material` land in the `pycvc` module and +// `pycvc.load_model(path)` returns a pycvc.model. It relies on the DoubleVector / +// boost::uint64_t typemaps + the %exception block set up in pycvc.i, and on the +// already-wrapped cvc::geometry / cvc::image. +// +// SWIG APPROACH (documented per the phase spec): +// * DIRECT WRAP of the real header (mirrors pycvc_image.i): %include model.h, +// curated with %ignore for the members that don't marshal. +// * The vectors model::meshes / model::materials are re-surfaced as PROPERTIES +// (model.meshes / model.materials) backed by %template'd std::vector proxies +// (MeshVector / MaterialVector) — indexable + len(). The raw members are +// %ignore'd because their std::vector type isn't %template'd yet at the point +// SWIG wraps the class body, so a %extend accessor placed AFTER the %template +// is what yields the real sequence proxy. +// * material's base_color / emissive are boost::array and DON'T +// marshal; base_color_texture is a by-value cvc::image member. All three are +// %ignore'd and re-exposed via a %extend with a DIFFERENT name (a same-named +// %extend is swallowed by the %ignore — the documented pycvc.i gotcha), then +// aliased to the natural name in a %pythoncode block (the pixel_format_* alias +// trick pycvc_image.i already uses). base_color()/emissive() return plain +// tuples; base_color_texture() returns a pycvc.image COPY (empty if none). +// * model::extents() returns the opaque bounding_box (as in pycvc.i, where every +// bounding_box return is ignored), so it is re-exposed as a 6-tuple +// (minx..maxz) the same way (extents_bbox %extend aliased to extents). +// * model::mesh::geom is re-exposed as geometry() (COW copy) — the raw member is +// %ignore'd and a distinct-named %extend is aliased back, same pattern. +// * load_model(path) is a %inline free function wrapping cvc::read_model — it +// takes ONLY a path (read_model needs no app; the returned geometries are bare +// value types with a null context, which every read-only accessor the bindings +// expose is fine with), so no app is threaded through. + +%{ +#include +#include // read_model (used by the %inline load_model below) +%} + +// model::mesh is a NESTED struct; SWIG (4.x) ignores nested classes by default +// (Warning 325), which would leave MeshVector's elements opaque. flatnested lifts +// it to a normal wrapped proxy so model.meshes[i].geometry()/.material/.name work. +%feature("flatnested", "1") cvc::model::mesh; + +// The vectors and the opaque bounding_box extents() are re-surfaced by %extend +// after the %template's below; %ignore the raw members so they don't wrap as +// opaque std::vector pointers. +%ignore cvc::model::meshes; +%ignore cvc::model::materials; +%ignore cvc::model::extents; +%ignore cvc::model::mesh::geom; + +// material: boost::array + by-value image members don't marshal cleanly; exposed +// via the renamed-%extend trick below. +%ignore cvc::material::base_color; +%ignore cvc::material::emissive; +%ignore cvc::material::base_color_texture; + +%include "cvc/model/model.h" + +// Sequence proxies for the mesh/material vectors (indexable + len()). Must come +// AFTER the %include so cvc::model::mesh / cvc::material are fully declared. +%template(MeshVector) std::vector; +%template(MaterialVector) std::vector; + +%extend cvc::model::mesh { + // The mesh's geometry as a pycvc.geometry (a COW copy of the mesh's cvc::geometry). + cvc::geometry mesh_geometry() const { return $self->geom; } +%pythoncode %{ + geometry = mesh_geometry +%} +} + +%extend cvc::material { + // base_color RGBA multiplier as a 4-tuple (the boost::array is ignored). + PyObject *base_color_rgba() const { + return Py_BuildValue("(dddd)", $self->base_color[0], $self->base_color[1], + $self->base_color[2], $self->base_color[3]); + } + // emissive RGB as a 3-tuple (the boost::array is ignored). + PyObject *emissive_rgb() const { + return Py_BuildValue("(ddd)", $self->emissive[0], $self->emissive[1], $self->emissive[2]); + } + // The LOADED base-color texture as a pycvc.image COPY (image.empty() if none / + // unresolved). Zero-copy sharing isn't offered here — the material owns the + // decoded pixels and a copy keeps the surface a plain value. + cvc::image base_color_texture_image() const { return $self->base_color_texture; } +%pythoncode %{ + base_color = base_color_rgba + emissive = emissive_rgb + base_color_texture = base_color_texture_image +%} +} + +%extend cvc::model { + // meshes / materials as real sequence proxies (the raw members are ignored; + // their std::vector type isn't templated at class-wrap time). Exposed as + // PROPERTIES below so `model.meshes[i]` / `model.materials[i]` read naturally. + std::vector mesh_list() const { return $self->meshes; } + std::vector material_list() const { return $self->materials; } + // extents() as a (minx, miny, minz, maxx, maxy, maxz) 6-tuple — the bounding_box + // return is opaque here exactly as in pycvc.i. + std::vector extents_bbox() const { + cvc::bounding_box b = $self->extents(); + return {b.minx, b.miny, b.minz, b.maxx, b.maxy, b.maxz}; + } +%pythoncode %{ + meshes = property(lambda self: self.mesh_list()) + materials = property(lambda self: self.material_list()) + extents = extents_bbox +%} +} + +// pycvc.load_model(path) — the core deliverable. Wraps cvc::read_model (which +// dispatches by extension to the Assimp handler for obj/ply/stl/fbx/gltf/glb/...). +// No app is needed; an unsupported extension / missing handler raises the same +// cvc::exception the %exception block maps to a Python RuntimeError. +%inline %{ +namespace pycvc { +cvc::model load_model(const std::string &path) { return cvc::read_model(path); } +} // namespace pycvc +%} diff --git a/bindings/pycvc/pymod_gl/scenes.py b/bindings/pycvc/pymod_gl/scenes.py index b7606b0..691b400 100644 --- a/bindings/pycvc/pymod_gl/scenes.py +++ b/bindings/pycvc/pymod_gl/scenes.py @@ -3,10 +3,12 @@ These are generic loaders for a ``geometry_bundle`` export — a ``terrain.json`` heightfield plus a ``buildings.glb`` (glTF 2.0) city mesh, as produced by the -CVC-DBG ``geometry-scene-gen`` tool (e.g. the Austin bundle). The glTF is read -with VTK's ``vtkGLTFReader`` (no trimesh/pygltflib needed) and added as a single -VTK prop; the terrain becomes a draped surface mesh; a bilinear ``sampler`` lets -you drape an agent onto the terrain. +CVC-DBG ``geometry-scene-gen`` tool (e.g. the Austin bundle). The glTF is loaded +NATIVELY via libcvc (``pycvc.load_model`` → ``cvc::model``, the Assimp-backed +mesh loader — no ``vtkGLTFReader``, no trimesh/pygltflib) and added as a single +native geometry node; the terrain becomes a draped surface mesh; a bilinear +``sampler`` lets you drape an agent onto the terrain. (``building_occupancy`` +below still rasterizes the mesh through VTK offscreen — a separate concern.) ATTRIBUTION: bundles generated from OpenStreetMap are © OpenStreetMap contributors and licensed under the Open Database License (ODbL, @@ -77,41 +79,39 @@ def add_terrain_json(lab, path: str, name: str = "terrain", color=(0.34, 0.40, 0 def add_gltf( lab, path: str, name: str, color=(0.74, 0.74, 0.78), opacity: float = 1.0, parent: str = "" ): - """Load a glTF/GLB mesh with VTK and add it to ``lab`` as one named prop node. - ``parent`` (default the root) makes it a CHILD of that node so it stays aligned - to and moves with it (e.g. buildings under the terrain). Returns the - ``vtkActor``. Needs the vtk-python wrappers (vtkmodules).""" - from vtkmodules.vtkIOGeometry import vtkGLTFReader - from vtkmodules.vtkFiltersGeometry import vtkCompositeDataGeometryFilter - from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper - - reader = vtkGLTFReader() - reader.SetFileName(path) - reader.Update() - # glTF comes back as a multiblock; flatten to one polydata. - geom = vtkCompositeDataGeometryFilter() - geom.SetInputConnection(reader.GetOutputPort()) - geom.Update() - pd = geom.GetOutput() - - mapper = vtkPolyDataMapper() - mapper.SetInputData(pd) - mapper.SetStatic(1) # geometry never changes -> VTK caches the VBO, no per-frame rebuild - mapper.ScalarVisibilityOff() # use the single material color, not any glTF scalars - actor = vtkActor() - actor.SetMapper(mapper) - prop = actor.GetProperty() - prop.SetColor(*color) - prop.SetOpacity(opacity) - # Ground-level views leave many building faces facing away from the light; a - # strong ambient term keeps them from going black so the city reads clearly. - prop.SetAmbient(0.45) - prop.SetDiffuse(0.7) - prop.SetSpecular(0.05) - - b = pd.GetBounds() # (xmin,xmax, ymin,ymax, zmin,zmax) - lab.add_prop(name, actor, (b[0], b[2], b[4], b[1], b[3], b[5]), parent=parent) - return actor + """Load a glTF/GLB (or any Assimp-supported) mesh NATIVELY via libcvc and add it + to ``lab`` as one named ``GeometryNode``. ``parent`` (default the root) makes it a + CHILD of that node so it stays aligned to and moves with it (e.g. buildings under + the terrain). Returns the live ``GeometryNode``. + + The whole file is flattened to a single ``cvc::geometry`` + (``pycvc.load_model(path).merged()``) and rendered with a UNIFORM + ``color``/``opacity`` — the demo deliberately renders a single-color city, not + per-glTF materials — matching the previous VTK path's look (ambient 0.45 / + diffuse 0.7 / specular 0.05). Imports NO VTK glTF reader; the scene node itself + still renders through cvcGL/VTK as usual.""" + pycvc = lab._pycvc + # Native load: one flattened geometry (single-color, one-node city mesh). + g = pycvc.load_model(path).merged() + + # Add via the native scene path, honoring parent (child inherits its transform). + scene = lab._scene + if parent: + node = scene.add_child_geometry(parent, name, g) + else: + node = scene.addGraphics(name, g) # downcast to the GeometryNode proxy + + # Uniform single-color material through the actor property (per-vertex colors go + # through VTK's LUT and mangle channels — see Lab.recolor). Ground-level views + # leave many building faces facing away from the light; a strong ambient term + # keeps them from going black so the city reads clearly. + node.setUseSingleColor(True) + node.setColor(*[float(c) for c in color]) + node.setOpacity(float(opacity)) + node.setAmbient(0.45) + node.setDiffuse(0.7) + node.setSpecular(0.05) + return node # ── grounded routing: keep a vehicle ON THE STREETS, out of the buildings ──── @@ -123,7 +123,12 @@ def add_gltf( def building_occupancy( - glb_path: str, bounds2d, nx: int = 512, ny: int = 512, inflate_m: float = 10.0, cache: bool = True + glb_path: str, + bounds2d, + nx: int = 512, + ny: int = 512, + inflate_m: float = 10.0, + cache: bool = True, ): """Rasterize ``buildings.glb`` into a SOLID boolean occupancy grid (``True`` = inside a building footprint) over ``bounds2d`` = ``(min_x, min_y, max_x, max_y)``. @@ -148,7 +153,11 @@ def building_occupancy( import numpy as np cache_path = "%s.occ_%dx%d_i%d.npy" % (glb_path, nx, ny, int(round(inflate_m))) - if cache and os.path.exists(cache_path) and os.path.getmtime(cache_path) >= os.path.getmtime(glb_path): + if ( + cache + and os.path.exists(cache_path) + and os.path.getmtime(cache_path) >= os.path.getmtime(glb_path) + ): return np.load(cache_path) # Register VTK's OpenGL2 render factory — in a raw Python interpreter (unlike the diff --git a/bindings/pycvc/test_pycvc_model.py b/bindings/pycvc/test_pycvc_model.py new file mode 100644 index 0000000..74e5153 --- /dev/null +++ b/bindings/pycvc/test_pycvc_model.py @@ -0,0 +1,180 @@ +"""Phase-6 pycvc.load_model + pycvc.model/material binding tests (core, no VTK). + +Covers the native mesh/model loader wrapped from cvc::model_file_io: +pycvc.load_model(path) → pycvc.model, its meshes/materials sequences, per-mesh +geometry (vertices/triangles/UVs), merged() flatten, and the material surface +(base_color/emissive tuples, metallic/roughness, base_color_texture_path, and the +loaded base_color_texture() as a pycvc.image). + +Mirrors the C++ model_test.cpp OBJ+MTL+PNG fixture: the PNG is authored through +pycvc.image.from_numpy + .save(), so a build with no image delegate (ImageMagick +off) skips the texture-specific assertions, and a build without the Assimp handler +skips the loader tests — exactly as the C++ test GTEST_SKIP()s. +""" + +import os +import tempfile + +import numpy as np +import pycvc + +# Authored material / texture constants (match model_test.cpp's write_obj_fixture). +_KD = (0.8, 0.2, 0.1) +_TEX_RGBA = (200, 100, 50, 255) +_TEX_W = _TEX_H = 4 + + +def _write_obj_fixture(): + """Write a tiny OBJ + MTL + 4x4 PNG into a temp dir; return (obj_path, + texture_ok). texture_ok is False when no image handler could write the PNG.""" + d = tempfile.mkdtemp(prefix="pycvc_model_") + obj = os.path.join(d, "cvc_model_test_mesh.obj") + mtl = os.path.join(d, "cvc_model_test_mesh.mtl") + png = os.path.join(d, "cvc_model_test_tex.png") + + # 4x4 RGBA texture, uniform color, via pycvc.image (needs an image handler). + texture_ok = False + try: + px = np.empty((_TEX_H, _TEX_W, 4), dtype=np.uint8) + px[:, :, 0] = _TEX_RGBA[0] + px[:, :, 1] = _TEX_RGBA[1] + px[:, :, 2] = _TEX_RGBA[2] + px[:, :, 3] = _TEX_RGBA[3] + pycvc.image.from_numpy(px).save(png) + texture_ok = True + except Exception: # noqa: BLE001 - no image delegate compiled in + texture_ok = False + + with open(mtl, "w") as om: + om.write("newmtl cvc_mat\n") + om.write("Kd 0.8 0.2 0.1\n") + om.write("d 1.0\n") + om.write("map_Kd cvc_model_test_tex.png\n") + with open(obj, "w") as oo: + oo.write("mtllib cvc_model_test_mesh.mtl\n") + oo.write("v 0 0 0\n") + oo.write("v 1 0 0\n") + oo.write("v 0 1 0\n") + oo.write("vt 0 0\n") + oo.write("vt 1 0\n") + oo.write("vt 0 1\n") + oo.write("usemtl cvc_mat\n") + oo.write("f 1/1 2/2 3/3\n") + return obj, texture_ok + + +def _load_or_skip(path): + """load_model(path) or None (printing a skip) when no handler is built.""" + try: + return pycvc.load_model(path) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "handler" in msg or "unsupported" in msg: + print(" skip: no model file handler built (Assimp off)") + return None + raise + + +def _uv_present(uvs, u, v): + return bool(np.any(np.all(np.isclose(uvs, [u, v], atol=1e-5), axis=1))) + + +# ── value-type: no handler / bad extension raises ─────────────────────── + + +def test_bad_extension_raises(): + try: + pycvc.load_model("/no/such/file.qwerty") + except Exception: # noqa: BLE001 - the expected cvc::exception -> RuntimeError + pass + else: + raise AssertionError("load_model must raise on an unsupported extension") + + +# ── loader: OBJ with UVs + material + texture ─────────────────────────── + + +def test_load_obj_mesh_and_merged(): + obj, _ = _write_obj_fixture() + m = _load_or_skip(obj) + if m is None: + return + assert not m.empty() + assert m.num_meshes() >= 1 + + mesh = m.meshes[0] + g = mesh.geometry() + assert g.num_vertices() == 3 + assert g.num_triangles() == 1 + + # UVs present and matching the authored (unflipped) coordinates. + uvs = g.uvs() + assert uvs.shape == (3, 2) + assert _uv_present(uvs, 0, 0) + assert _uv_present(uvs, 1, 0) + assert _uv_present(uvs, 0, 1) + + # merged() flattens to a single geometry with the same counts (one mesh here). + merged = m.merged() + assert merged.num_vertices() == 3 + assert merged.num_triangles() == 1 + + +def test_load_obj_material(): + obj, texture_ok = _write_obj_fixture() + m = _load_or_skip(obj) + if m is None: + return + + mesh = m.meshes[0] + assert mesh.material >= 0 + assert mesh.material < len(m.materials) + mat = m.materials[mesh.material] + + # Kd -> base_color rgb, d=1 -> alpha (a plain 4-tuple). + bc = mat.base_color() + assert len(bc) == 4 + assert np.allclose(bc[:3], _KD, atol=1e-3) + assert abs(bc[3] - 1.0) < 1e-3 + # emissive default (0,0,0), and the scalar PBR fields marshal as floats. + assert len(mat.emissive()) == 3 + assert isinstance(mat.metallic, float) + assert isinstance(mat.roughness, float) + + assert mat.base_color_texture_path == "cvc_model_test_tex.png" + + if not texture_ok: + print(" skip: texture assertions (no image delegate to author the PNG)") + return + assert mat.has_base_color_texture() + tex = mat.base_color_texture() + assert not tex.empty() + assert tex.width() == _TEX_W and tex.height() == _TEX_H + assert tex.channels() == 4 + # Decoded pixels round-trip (PNG is lossless; uniform color -> flip-invariant). + px = tex.numpy() + assert px.shape == (_TEX_H, _TEX_W, 4) + assert tuple(int(v) for v in px[0, 0]) == _TEX_RGBA + + +def test_extents_is_six_tuple(): + obj, _ = _write_obj_fixture() + m = _load_or_skip(obj) + if m is None: + return + e = m.extents() + # (minx, miny, minz, maxx, maxy, maxz) as a 6-element sequence of finite floats. + # NOTE: the fixture is a single FLAT (z=0) triangle, whose bbox is zero-volume; + # cvc::bounding_box::operator+ treats a zero-volume box as the identity (see + # model.cpp / the C++ ExtentsUnion test, which uses a non-planar mesh on + # purpose), so this particular model's union collapses to a null (all-zero) + # box. We assert the marshaling (shape + finiteness), not specific extents. + assert len(e) == 6 + assert all(np.isfinite(v) for v in e) + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print("pycvc model tests: OK") From 40d5306f59dcb1d69fb179415cb4b4ddc0828924 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 26 Jul 2026 22:21:07 -0500 Subject: [PATCH 2/2] test(phase6): assert concrete extents() values via a non-planar fixture Adversarial review flagged test_extents_is_six_tuple as toothless: the flat z=0 triangle fixture's bbox collapses to all-zeros, so asserting only len==6 + finiteness could not distinguish a correct extents_bbox marshaling from a swapped/transposed/default one. Add a non-planar tetra fixture (bbox x[0,2] y[0,3] z[0,4]) and assert the concrete (minx..maxz) values, so the (minx,miny,minz,maxx,maxy,maxz) order is actually proven. Passes 1/1. --- bindings/pycvc/test_pycvc_model.py | 37 ++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/bindings/pycvc/test_pycvc_model.py b/bindings/pycvc/test_pycvc_model.py index 74e5153..4cb3b8d 100644 --- a/bindings/pycvc/test_pycvc_model.py +++ b/bindings/pycvc/test_pycvc_model.py @@ -63,6 +63,25 @@ def _write_obj_fixture(): return obj, texture_ok +def _write_tetra_obj_fixture(): + """Write a NON-planar tetrahedron OBJ with a known, non-zero-volume bbox + (x∈[0,2], y∈[0,3], z∈[0,4]) so extents() has concrete values to assert — a + flat triangle's bbox collapses to the null/identity box (see the C++ + ExtentsUnion test, which uses non-planar geometry for the same reason).""" + d = tempfile.mkdtemp(prefix="pycvc_model_tetra_") + obj = os.path.join(d, "tetra.obj") + with open(obj, "w") as oo: + oo.write("v 0 0 0\n") + oo.write("v 2 0 0\n") + oo.write("v 0 3 0\n") + oo.write("v 0 0 4\n") + oo.write("f 1 2 3\n") + oo.write("f 1 2 4\n") + oo.write("f 1 3 4\n") + oo.write("f 2 3 4\n") + return obj + + def _load_or_skip(path): """load_model(path) or None (printing a skip) when no handler is built.""" try: @@ -157,20 +176,20 @@ def test_load_obj_material(): assert tuple(int(v) for v in px[0, 0]) == _TEX_RGBA -def test_extents_is_six_tuple(): - obj, _ = _write_obj_fixture() - m = _load_or_skip(obj) +def test_extents_values(): + # A non-planar tetra (bbox x∈[0,2], y∈[0,3], z∈[0,4]) so extents() has real + # values — this proves the (minx,miny,minz,maxx,maxy,maxz) marshaling ORDER, + # which an all-zero flat-triangle bbox could not distinguish from a broken + # (swapped/transposed/default) binding. + m = _load_or_skip(_write_tetra_obj_fixture()) if m is None: return e = m.extents() - # (minx, miny, minz, maxx, maxy, maxz) as a 6-element sequence of finite floats. - # NOTE: the fixture is a single FLAT (z=0) triangle, whose bbox is zero-volume; - # cvc::bounding_box::operator+ treats a zero-volume box as the identity (see - # model.cpp / the C++ ExtentsUnion test, which uses a non-planar mesh on - # purpose), so this particular model's union collapses to a null (all-zero) - # box. We assert the marshaling (shape + finiteness), not specific extents. assert len(e) == 6 assert all(np.isfinite(v) for v in e) + minx, miny, minz, maxx, maxy, maxz = e + assert np.isclose(minx, 0.0) and np.isclose(miny, 0.0) and np.isclose(minz, 0.0) + assert np.isclose(maxx, 2.0) and np.isclose(maxy, 3.0) and np.isclose(maxz, 4.0) if __name__ == "__main__":