Skip to content

Commit

Permalink
Add tests (#7)
Browse files Browse the repository at this point in the history
* add tests

* add workflow

* fix workflow
  • Loading branch information
kmarchais authored Jul 18, 2024
1 parent 23127ad commit 6bb2f99
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 49 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Build and Test

on:
workflow_dispatch:
pull_request:
push:
branches:
- "*"

schedule:
- cron: "0 0 * * 0"

jobs:
build-and-test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
python-version: ["3.10", "3.11"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"

- name: Install
run: |
pip install uv
uv pip install --system blender_tpms@. bpy pytest
- name: Test
run: pytest tests
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
__pycache__
__pycache__
.coverage
9 changes: 2 additions & 7 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Blender TPMS addon to generate TPMS meshes."""

module = "blender_tpms"
try:
blender_tpms = __import__(module)
import blender_tpms
except ImportError:
import importlib
import site
Expand All @@ -24,7 +23,7 @@
if user_site not in sys.path:
sys.path.append(user_site)

blender_tpms = importlib.import_module(module)
importlib.import_module("blender_tpms")

bl_info = {
"name": "TPMS",
Expand All @@ -49,7 +48,3 @@ def register() -> None:
def unregister() -> None:
"""Unregister the addon."""
blender_tpms.unregister()


register()
unregister()
2 changes: 1 addition & 1 deletion src/blender_tpms/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import numpy as np

if TYPE_CHECKING:
import pyvista as pv
import pyvista as pv # pragma: no cover


def polydata_to_mesh(polydata: pv.PolyData, mesh_name: str = "Tpms") -> bpy.types.Mesh:
Expand Down
157 changes: 120 additions & 37 deletions src/blender_tpms/material.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,48 @@
import numpy as np

if TYPE_CHECKING:
from blender_tpms.tpms.tpms import Tpms
from blender_tpms.tpms.tpms import Tpms # pragma: no cover


def apply_material(
mesh: bpy.types.Mesh,
tpms: Tpms,
def shader_node_attribute(
material: bpy.types.Material,
attr_name: str,
colormap: str,
n_colors: int,
) -> None:
"""Apply a material to the mesh based on the TPMS field."""
if bpy.context.space_data.shading.type not in ["MATERIAL", "RENDERED"]:
bpy.context.space_data.shading.type = "MATERIAL"

if attr_name not in bpy.data.materials:
material = bpy.data.materials.new(name=attr_name)
material = bpy.data.materials[attr_name]
material.use_nodes = True

bsdf = material.node_tree.nodes["Principled BSDF"]
material_output_node = material.node_tree.nodes["Material Output"]

) -> bpy.types.ShaderNodeAttribute:
"""Create an attribute node to get the TPMS field values."""
attribute_node = material.node_tree.nodes.new("ShaderNodeAttribute")
if not isinstance(attribute_node, bpy.types.ShaderNodeAttribute):
raise TypeError("Shader node is not ShaderNodeAttribute") # pragma: no cover

attribute_node.attribute_name = attr_name
return attribute_node


def shader_node_map_range(
material: bpy.types.Material,
attr_name: str,
tpms: Tpms,
) -> bpy.types.ShaderNodeMapRange:
"""Create a map range node to map the attribute values to the color ramp."""
map_range_node = material.node_tree.nodes.new("ShaderNodeMapRange")
if not isinstance(map_range_node, bpy.types.ShaderNodeMapRange):
raise TypeError("Shader node is not ShaderNodeMapRange") # pragma: no cover

map_range_node.inputs["From Min"].default_value = np.min(tpms.vtk_mesh[attr_name])
map_range_node.inputs["From Max"].default_value = np.max(tpms.vtk_mesh[attr_name])
return map_range_node


def shader_node_val_to_rgb(
material: bpy.types.Material,
colormap: str,
n_colors: int,
) -> bpy.types.ShaderNodeValToRGB:
"""Create a color ramp node with a colormap."""
color_ramp_node = material.node_tree.nodes.new("ShaderNodeValToRGB")

if not isinstance(color_ramp_node, bpy.types.ShaderNodeValToRGB):
raise TypeError("Shader node is not ShaderNodeVal") # pragma: no cover

# remove to create it again in the last iteration to have it selected
last_elem = color_ramp_node.color_ramp.elements[-1]
color_ramp_node.color_ramp.elements.remove(last_elem)
Expand All @@ -52,27 +63,19 @@ def apply_material(
color = [c**2.2 for c in cmap(location)] # sRGB to Linear RGB
color_ramp_node.color_ramp.elements[i].color = color

color_ramp_node.select = False
color_ramp_node.location = (
bsdf.location.x - color_ramp_node.width - 50,
bsdf.location.y,
)
color_ramp_node.label = colormap
return color_ramp_node

bsdf.select = False

attribute_node.select = False
map_range_node.select = False

map_range_node.location = (
color_ramp_node.location.x - map_range_node.width - 50,
bsdf.location.y,
)
attribute_node.location = (
map_range_node.location.x - attribute_node.width - 50,
bsdf.location.y,
)

def link_nodes(
material: bpy.types.Material,
bsdf: bpy.types.Node,
attribute_node: bpy.types.Node,
map_range_node: bpy.types.Node,
color_ramp_node: bpy.types.Node,
material_output_node: bpy.types.Node,
) -> None:
"""Link the nodes to create the material."""
material.node_tree.links.new(
attribute_node.outputs["Fac"],
map_range_node.inputs["Value"],
Expand All @@ -91,4 +94,84 @@ def apply_material(
material_output_node.inputs["Surface"],
)


def move_nodes(
bsdf: bpy.types.Node,
attribute_node: bpy.types.Node,
map_range_node: bpy.types.Node,
color_ramp_node: bpy.types.Node,
) -> None:
"""Move the nodes so they are not on top of each other."""
bsdf.select = False
color_ramp_node.select = False
attribute_node.select = False
map_range_node.select = False

color_ramp_node.location = (
bsdf.location.x - color_ramp_node.width - 50,
bsdf.location.y,
)

map_range_node.location = (
color_ramp_node.location.x - map_range_node.width - 50,
bsdf.location.y,
)
attribute_node.location = (
map_range_node.location.x - attribute_node.width - 50,
bsdf.location.y,
)


def switch_to_material_shading() -> None:
"""Switch to material shading if not already in material or rendered mode."""
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
for space in area.spaces:
if (
space.type == "VIEW_3D"
and isinstance(space, bpy.types.SpaceView3D)
and space.shading.type not in ["MATERIAL", "RENDERED"]
):
space.shading.type = "MATERIAL"
return


def create_material(attr_name: str) -> bpy.types.Material:
"""Create a new material or get the existing one and activate the nodes."""
if attr_name not in bpy.data.materials:
material = bpy.data.materials.new(name=attr_name)
material = bpy.data.materials[attr_name]
material.use_nodes = True
return material


def apply_material(
mesh: bpy.types.Mesh,
tpms: Tpms,
attr_name: str,
colormap: str,
n_colors: int,
) -> None:
"""Apply a material to the mesh based on the TPMS field."""
switch_to_material_shading()

material = create_material(attr_name)

bsdf = material.node_tree.nodes["Principled BSDF"]
attribute_node = shader_node_attribute(material, attr_name)
map_range_node = shader_node_map_range(material, attr_name, tpms)
color_ramp_node = shader_node_val_to_rgb(material, colormap, n_colors)
material_output_node = material.node_tree.nodes["Material Output"]

move_nodes(bsdf, attribute_node, map_range_node, color_ramp_node)

link_nodes(
material,
bsdf,
attribute_node,
map_range_node,
color_ramp_node,
material_output_node,
)

bpy.data.objects[mesh.name].data.materials.append(material)
6 changes: 3 additions & 3 deletions src/blender_tpms/tpms/tpms.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,18 @@ def _init_cell_parameters(
err_msg = "repeat_cell must be an int or a sequence of 3 ints"
raise ValueError(err_msg)

def vtk_sheet(self) -> pv.PolyData:
def vtk_sheet(self) -> pv.UnstructuredGrid:
"""Sheet surface of the TPMS geometry."""
return self.grid.clip_scalar(scalars="upper_surface").clip_scalar(
scalars="lower_surface",
invert=False,
)

def vtk_upper_skeletal(self) -> pv.PolyData:
def vtk_upper_skeletal(self) -> pv.UnstructuredGrid:
"""Upper skeletal surface of the TPMS geometry."""
return self.grid.clip_scalar(scalars="upper_surface", invert=False)

def vtk_lower_skeletal(self) -> pv.PolyData:
def vtk_lower_skeletal(self) -> pv.UnstructuredGrid:
"""Lower skeletal surface of the TPMS geometry."""
return self.grid.clip_scalar(scalars="lower_surface")

Expand Down
25 changes: 25 additions & 0 deletions tests/test_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pyvista as pv
from blender_tpms.interface import get_all_surfaces, polydata_to_mesh

# ruff: noqa: S101


def test_polydata_to_mesh() -> None:
"""Test polydata_to_mesh function."""
polydata = pv.Cube()
mesh = polydata_to_mesh(polydata, mesh_name="Box")
assert mesh.name == "Box"
assert len(mesh.vertices) == 8
assert len(mesh.polygons) == 12


def test_get_all_surfaces() -> None:
"""Test get_all_surfaces function."""
surfaces = get_all_surfaces()
assert len(surfaces) == 30
for surface in surfaces:
assert len(surface) == 3
assert isinstance(surface[0], str)
assert isinstance(surface[1], str)
assert isinstance(surface[2], str)
assert surface[0] == surface[1] == surface[2]
35 changes: 35 additions & 0 deletions tests/test_operators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import blender_tpms.tpms
import bpy
from blender_tpms.interface import polydata_to_mesh
from blender_tpms.ui import apply_material, set_shade_auto_smooth


def test_auto_smooth() -> None:
tpms = blender_tpms.tpms.Tpms()
mesh = polydata_to_mesh(tpms.sheet, mesh_name="Tpms")

obj = bpy.data.objects.new(mesh.name, mesh)
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(state=True)

assert mesh.name == "Tpms"
set_shade_auto_smooth()


def test_apply_material() -> None:
tpms = blender_tpms.tpms.Tpms()
mesh = polydata_to_mesh(tpms.sheet, mesh_name="Tpms")

obj = bpy.data.objects.new(mesh.name, mesh)
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(state=True)

apply_material(
mesh=mesh,
tpms=tpms,
attr_name="surface",
colormap="coolwarm",
n_colors=9,
)
15 changes: 15 additions & 0 deletions tests/test_surfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from inspect import getmembers, isfunction
from typing import Callable

import numpy as np
import pytest
from blender_tpms.tpms import surfaces


@pytest.mark.parametrize(
"surface_function",
[func[1] for func in getmembers(surfaces, isfunction)],
)
def test_surfaces(surface_function: Callable) -> None:
"""Test all TPMS surfaces."""
assert -np.inf < surface_function(0, 0, 0) < np.inf
27 changes: 27 additions & 0 deletions tests/test_tpms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from blender_tpms.tpms import CylindricalTpms, SphericalTpms, Tpms


def test_tpms() -> None:
tpms = Tpms()
assert tpms is not None
assert tpms.surface is not None
assert tpms.sheet is not None
assert tpms.lower_skeletal is not None
assert tpms.upper_skeletal is not None
assert tpms.skeletals is not None
assert tpms.relative_density > 0
assert tpms.vtk_sheet() is not None
assert tpms.vtk_lower_skeletal() is not None
assert tpms.vtk_upper_skeletal() is not None


def test_cylindrical_tpms() -> None:
tpms = CylindricalTpms()

assert tpms.relative_density > 0


def test_spherical_tpms() -> None:
tpms = SphericalTpms()

assert tpms.relative_density > 0

0 comments on commit 6bb2f99

Please sign in to comment.