Blender assignMaterial: colour, idempotent slot, malformed colour refused #6

Merged
libby merged 2 commits from libby/blender-material-colour into main 2026-11-10 18:00:38 +00:00
5 changed files with 247 additions and 6 deletions

View file

@ -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 "")

View file

@ -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

View file

@ -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`,

View 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)

View 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()