Blender assignMaterial: colour, idempotent slot, malformed colour refused #6
5 changed files with 247 additions and 6 deletions
|
|
@ -610,12 +610,22 @@ class BrokkrBlenderTarget:
|
|||
if obj is None:
|
||||
return self._receipt(command, "failed", "Target Blender object was not found.", "")
|
||||
|
||||
if not (obj.data and hasattr(obj.data, "materials")):
|
||||
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
|
||||
rgba = sa.color_rgba(command) # refused before anything is created or changed
|
||||
material_name = command.get("materialName") or "Brokkr Material"
|
||||
material = self.bpy.data.materials.get(material_name) or self.bpy.data.materials.new(material_name)
|
||||
if obj.data and hasattr(obj.data, "materials"):
|
||||
if rgba is not None:
|
||||
if not material.use_nodes or material.node_tree is None:
|
||||
material.use_nodes = True
|
||||
nodes = material.node_tree.nodes
|
||||
principled = nodes.get("Principled BSDF") or next(
|
||||
(n for n in nodes if n.bl_idname == "ShaderNodeBsdfPrincipled"), None) or nodes.new("ShaderNodeBsdfPrincipled")
|
||||
principled.inputs["Base Color"].default_value = rgba
|
||||
material.diffuse_color = rgba
|
||||
if not any(slot is not None and slot.name == material.name for slot in obj.data.materials):
|
||||
obj.data.materials.append(material)
|
||||
return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
|
||||
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
|
||||
return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
|
||||
|
||||
def _import_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(command.get("filepath") or "")
|
||||
|
|
|
|||
|
|
@ -187,6 +187,15 @@ def integer(command: dict[str, Any], key: str, low: int, high: int) -> int | Non
|
|||
return int(value)
|
||||
|
||||
|
||||
def unit_component(key: str, item: Any) -> float:
|
||||
"""One colour component: a number in [0, 1]. The range test comes first: it also refuses nan and inf,
|
||||
and a huge int (10**400) never reaches math.isfinite, which would raise OverflowError instead of
|
||||
naming the field."""
|
||||
if isinstance(item, bool) or not isinstance(item, (int, float)) or not 0.0 <= item <= 1.0:
|
||||
raise ActionRefused(f"{key} components must be numbers between 0 and 1.")
|
||||
return float(item)
|
||||
|
||||
|
||||
def color(command: dict[str, Any], key: str = "color") -> tuple[float, float, float] | None:
|
||||
"""Linear RGB, three finite numbers in [0, 1], or None when absent."""
|
||||
if key not in command or command[key] is None:
|
||||
|
|
@ -196,12 +205,23 @@ def color(command: dict[str, Any], key: str = "color") -> tuple[float, float, fl
|
|||
raise ActionRefused(f"{key} must be three numbers [r, g, b].")
|
||||
parts = []
|
||||
for item in value:
|
||||
if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item) or not 0.0 <= item <= 1.0:
|
||||
raise ActionRefused(f"{key} components must be numbers between 0 and 1.")
|
||||
parts.append(float(item))
|
||||
parts.append(unit_component(key, item))
|
||||
return (parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
def color_rgba(command: dict[str, Any], key: str = "color") -> tuple[float, float, float, float] | None:
|
||||
"""Linear RGB or RGBA, finite numbers in [0, 1] (RGB gets alpha 1), or None when absent."""
|
||||
if key not in command or command[key] is None:
|
||||
return None
|
||||
value = command[key]
|
||||
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
||||
raise ActionRefused(f"{key} must be [r, g, b] or [r, g, b, a].")
|
||||
parts = []
|
||||
for item in value:
|
||||
parts.append(unit_component(key, item))
|
||||
return (parts[0], parts[1], parts[2], parts[3] if len(parts) == 4 else 1.0)
|
||||
|
||||
|
||||
def choice(command: dict[str, Any], key: str, allowed: Iterable[str]) -> str | None:
|
||||
if key not in command or command[key] is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ gets a `failed` receipt whose message names the rule):
|
|||
`color` (linear `[r, g, b]`, each 0 to 1), `lightType` (`POINT`, `SUN`, `SPOT`, `AREA`).
|
||||
- `setWorld`: optional `worldName` (default: the scene's world, created if there is
|
||||
none), `color` (`[r, g, b]`), `strength`. Edits the world's Background node.
|
||||
- `assignMaterial`: `targetObjectName` (an object with material slots, such as a mesh);
|
||||
optional `materialName` (default `Brokkr Material`; an existing material of that name
|
||||
is reused) and `color` (linear `[r, g, b]` or `[r, g, b, a]`, each a finite number 0
|
||||
to 1; alpha defaults to 1). `color` sets the Principled BSDF Base Color (the node tree
|
||||
is created if the material has none) and `diffuse_color`; a malformed `color` is
|
||||
refused naming the field and nothing is created or changed (`"color": null` counts as absent). Without `color` an
|
||||
existing material's colour is left as it is. Idempotent per (object, material): if the
|
||||
object already has a slot with that material no second slot is added. Other keys are
|
||||
ignored, as for every action.
|
||||
- `renderStill`: `outputPath` relative to the preference `Render Output Directory`,
|
||||
ending `.png`, `.jpg` or `.exr` (same containment rules as import); optional
|
||||
`engine` (any engine of the running Blender, case-insensitive), `resolutionX`,
|
||||
|
|
|
|||
55
surfaces/blender/tests/live/material_colour.py
Normal file
55
surfaces/blender/tests/live/material_colour.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Live proof of assignMaterial colour and idempotence on real bpy.
|
||||
|
||||
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
|
||||
blender -b --factory-startup --python material_colour.py -- result.json
|
||||
|
||||
Exit status is non-zero if any check fails.
|
||||
"""
|
||||
import bpy, sys, os, json, types
|
||||
from pathlib import Path
|
||||
|
||||
ADDON = Path(os.environ["BROKKR_ADDON"]).resolve()
|
||||
pkg = types.ModuleType("brokkr_bridge"); pkg.__path__ = [str(ADDON)]
|
||||
sys.modules["brokkr_bridge"] = pkg
|
||||
from brokkr_bridge import blender_target as bt
|
||||
|
||||
out = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "material_colour_result.json"
|
||||
R = {"blender": bpy.app.version_string, "checks": []}
|
||||
|
||||
def check(name, ok, detail=None):
|
||||
R["checks"].append({"name": name, "ok": bool(ok), "detail": detail}); print("CHECK", "ok " if ok else "FAIL", name, detail if not ok else "", flush=True)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
t = bt.BrokkrBlenderTarget(bpy)
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
cube = bpy.context.active_object
|
||||
N = [0]
|
||||
def act(**cmd):
|
||||
N[0] += 1; cmd.setdefault("commandId", "c%d" % N[0]); cmd["action"] = "assignMaterial"; cmd["targetObjectName"] = cube.name
|
||||
return t.execute_command(bpy.context, cmd)
|
||||
|
||||
def base():
|
||||
return tuple(round(c, 6) for c in bpy.data.materials["oxide red"].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value)
|
||||
|
||||
r = act(materialName="oxide red", color=[0.56, 0.2, 0.12])
|
||||
check("oxide red accepted", r["status"] == "accepted", r["message"])
|
||||
check("principled base color", base() == (0.56, 0.2, 0.12, 1.0), base())
|
||||
m = bpy.data.materials["oxide red"]
|
||||
check("diffuse_color", tuple(round(c, 6) for c in m.diffuse_color) == (0.56, 0.2, 0.12, 1.0), tuple(m.diffuse_color))
|
||||
check("one slot", len(cube.material_slots) == 1, len(cube.material_slots))
|
||||
r = act(materialName="oxide red", color=[0.56, 0.2, 0.12])
|
||||
check("repeat accepted", r["status"] == "accepted", r["message"])
|
||||
check("repeat keeps one slot", len(cube.material_slots) == 1, len(cube.material_slots))
|
||||
r = act(materialName="oxide red")
|
||||
check("no color leaves colour", r["status"] == "accepted" and base() == (0.56, 0.2, 0.12, 1.0), base())
|
||||
r = act(materialName="oxide red", color=[0.1, 0.2, 0.3, 0.5])
|
||||
check("rgba recolours", base() == (0.1, 0.2, 0.3, 0.5) and len(cube.material_slots) == 1, base())
|
||||
for bad in ([1, 2], [2, 0, 0], [float("nan"), 0, 0], "red"):
|
||||
r = act(materialName="oxide red", color=bad)
|
||||
check("bad colour refused %r" % (bad,), r["status"] == "failed" and "color" in r["message"], r["message"])
|
||||
check("refusals left colour", base() == (0.1, 0.2, 0.3, 0.5), base())
|
||||
r = act(materialName="never made", color=[7, 0, 0])
|
||||
check("refusal creates no material", "never made" not in bpy.data.materials)
|
||||
|
||||
json.dump(R, open(out, "w"), indent=1)
|
||||
sys.exit(0 if all(c["ok"] for c in R["checks"]) else 1)
|
||||
147
surfaces/blender/tests/test_material_actions.py
Normal file
147
surfaces/blender/tests/test_material_actions.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""assignMaterial tests. No Blender: a fake bpy with materials, slots and a Principled node.
|
||||
|
||||
Run: python3 -m unittest discover -s surfaces/blender/tests -v
|
||||
The real API is exercised by tests/live/material_colour.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ADDON = Path(__file__).resolve().parents[1] / "brokkr_bridge"
|
||||
package = types.ModuleType("brokkr_bridge")
|
||||
package.__path__ = [str(ADDON)]
|
||||
sys.modules.setdefault("brokkr_bridge", package)
|
||||
|
||||
from brokkr_bridge import blender_target as bt # noqa: E402
|
||||
|
||||
NS = types.SimpleNamespace
|
||||
|
||||
|
||||
class Nodes(dict):
|
||||
def new(self, idname):
|
||||
node = NS(bl_idname=idname, inputs={"Base Color": NS(default_value=(0.8, 0.8, 0.8, 1.0))})
|
||||
self["Principled BSDF"] = node
|
||||
return node
|
||||
|
||||
|
||||
class Material:
|
||||
def __init__(self, name):
|
||||
self.name, self.diffuse_color, self.node_tree = name, (0.8, 0.8, 0.8, 1.0), None
|
||||
self._use_nodes = False
|
||||
|
||||
@property
|
||||
def use_nodes(self):
|
||||
return self._use_nodes
|
||||
|
||||
@use_nodes.setter
|
||||
def use_nodes(self, value): # Blender creates the default tree, with a Principled BSDF, on first use
|
||||
self._use_nodes = value
|
||||
if value and self.node_tree is None:
|
||||
self.node_tree = NS(nodes=Nodes())
|
||||
self.node_tree.nodes.new("ShaderNodeBsdfPrincipled")
|
||||
|
||||
|
||||
class Materials(dict):
|
||||
def new(self, name):
|
||||
self[name] = Material(name)
|
||||
return self[name]
|
||||
|
||||
|
||||
class FakeBpy:
|
||||
def __init__(self):
|
||||
self.mesh = NS(name="Cube", type="MESH", data=NS(materials=[]))
|
||||
self.empty = NS(name="Empty", type="EMPTY", data=None)
|
||||
self.data = NS(objects={"Cube": self.mesh, "Empty": self.empty}, materials=Materials())
|
||||
|
||||
|
||||
class MaterialTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bpy = FakeBpy()
|
||||
self.target = bt.BrokkrBlenderTarget(self.bpy)
|
||||
|
||||
def assign(self, **fields):
|
||||
return self.target.execute_command(NS(), {"commandId": "c1", "action": "assignMaterial",
|
||||
"targetObjectName": "Cube", **fields})
|
||||
|
||||
def base_color(self, name="oxide red"):
|
||||
return tuple(self.bpy.data.materials[name].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value)
|
||||
|
||||
def test_rgb_colour_sets_base_color_and_diffuse_with_alpha_one(self):
|
||||
receipt = self.assign(materialName="oxide red", color=[0.56, 0.2, 0.12])
|
||||
self.assertEqual(receipt["status"], "accepted", receipt["message"])
|
||||
material = self.bpy.data.materials["oxide red"]
|
||||
self.assertEqual(self.base_color(), (0.56, 0.2, 0.12, 1.0))
|
||||
self.assertEqual(tuple(material.diffuse_color), (0.56, 0.2, 0.12, 1.0))
|
||||
self.assertTrue(material.use_nodes)
|
||||
self.assertEqual(self.bpy.mesh.data.materials, [material])
|
||||
|
||||
def test_rgba_colour_keeps_its_alpha(self):
|
||||
self.assign(materialName="glass", color=[0.1, 0.2, 0.3, 0.5])
|
||||
self.assertEqual(self.base_color("glass"), (0.1, 0.2, 0.3, 0.5))
|
||||
self.assertEqual(tuple(self.bpy.data.materials["glass"].diffuse_color), (0.1, 0.2, 0.3, 0.5))
|
||||
|
||||
def test_the_colour_limits_themselves_are_accepted(self):
|
||||
self.assign(materialName="black", color=[0, 0, 0])
|
||||
self.assertEqual(self.base_color("black"), (0.0, 0.0, 0.0, 1.0))
|
||||
self.assign(materialName="white", color=[1, 1, 1, 1])
|
||||
self.assertEqual(self.base_color("white"), (1.0, 1.0, 1.0, 1.0))
|
||||
self.assign(materialName="clear", color=[1.0, 0.0, 0.5, 0])
|
||||
self.assertEqual(self.base_color("clear"), (1.0, 0.0, 0.5, 0.0))
|
||||
|
||||
def test_an_explicit_null_colour_is_no_colour(self):
|
||||
self.assertEqual(self.assign(materialName="plain", color=None)["status"], "accepted")
|
||||
self.assertEqual(tuple(self.bpy.data.materials["plain"].diffuse_color), (0.8, 0.8, 0.8, 1.0))
|
||||
|
||||
def test_no_colour_leaves_a_new_material_at_blender_defaults(self):
|
||||
self.assertEqual(self.assign(materialName="plain")["status"], "accepted")
|
||||
self.assertEqual(tuple(self.bpy.data.materials["plain"].diffuse_color), (0.8, 0.8, 0.8, 1.0))
|
||||
|
||||
def test_malformed_colour_is_refused_naming_color_and_changes_nothing(self):
|
||||
for bad in ([1], [1, 0], [0, 0, 0, 1, 1], "red", 0.5, {"r": 1}, ["a", 0, 0], [True, 0, 0], [None, 0, 0],
|
||||
[float("nan"), 0, 0], [float("inf"), 0, 0], [-0.1, 0, 0], [1.1, 0, 0], [0, 0, 0, 2],
|
||||
[1.0000001, 0, 0], [-1e-9, 0, 0], [0, 0, 0, 1.0000001], [10**400, 0, 0], [0, -10**400, 0]):
|
||||
receipt = self.assign(materialName="oxide red", color=bad)
|
||||
self.assertEqual(receipt["status"], "failed", bad)
|
||||
self.assertIn("color", receipt["message"], bad)
|
||||
self.assertEqual(dict(self.bpy.data.materials), {})
|
||||
self.assertEqual(self.bpy.mesh.data.materials, [])
|
||||
|
||||
def test_assigning_twice_keeps_one_slot(self):
|
||||
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
|
||||
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
|
||||
self.assertEqual(len(self.bpy.mesh.data.materials), 1)
|
||||
self.assign(materialName="charcoal")
|
||||
self.assertEqual([m.name for m in self.bpy.mesh.data.materials], ["oxide red", "charcoal"])
|
||||
|
||||
def test_reusing_a_material_recolours_only_when_colour_is_given(self):
|
||||
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
|
||||
self.assign(materialName="oxide red")
|
||||
self.assertEqual(self.base_color(), (0.5, 0.1, 0.1, 1.0))
|
||||
self.assign(materialName="oxide red", color=[0.9, 0.9, 0.9])
|
||||
self.assertEqual(self.base_color(), (0.9, 0.9, 0.9, 1.0))
|
||||
self.assertEqual(len(self.bpy.mesh.data.materials), 1)
|
||||
|
||||
def test_refused_colour_on_an_existing_material_leaves_it_alone(self):
|
||||
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
|
||||
self.assertEqual(self.assign(materialName="oxide red", color=[2, 0, 0])["status"], "failed")
|
||||
self.assertEqual(self.base_color(), (0.5, 0.1, 0.1, 1.0))
|
||||
|
||||
def test_existing_tree_without_a_principled_node_gets_one(self):
|
||||
material = self.bpy.data.materials.new("bare")
|
||||
material.use_nodes = True
|
||||
material.node_tree.nodes.clear()
|
||||
self.assertEqual(self.assign(materialName="bare", color=[0.2, 0.3, 0.4])["status"], "accepted")
|
||||
self.assertEqual(self.base_color("bare"), (0.2, 0.3, 0.4, 1.0))
|
||||
|
||||
def test_object_without_material_slots_is_refused(self):
|
||||
receipt = self.target.execute_command(NS(), {"commandId": "c", "action": "assignMaterial",
|
||||
"targetObjectName": "Empty", "color": [1, 0, 0]})
|
||||
self.assertEqual(receipt["status"], "failed")
|
||||
self.assertEqual(dict(self.bpy.data.materials), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue