diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index 628c199..3d86317 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -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 "") diff --git a/surfaces/blender/brokkr_bridge/scene_actions.py b/surfaces/blender/brokkr_bridge/scene_actions.py index ef0ada7..415185c 100644 --- a/surfaces/blender/brokkr_bridge/scene_actions.py +++ b/surfaces/blender/brokkr_bridge/scene_actions.py @@ -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 diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index 6548af4..3d01134 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -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`, diff --git a/surfaces/blender/tests/live/material_colour.py b/surfaces/blender/tests/live/material_colour.py new file mode 100644 index 0000000..4b43135 --- /dev/null +++ b/surfaces/blender/tests/live/material_colour.py @@ -0,0 +1,55 @@ +"""Live proof of assignMaterial colour and idempotence on real bpy. + + BROKKR_ADDON= \ + 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) diff --git a/surfaces/blender/tests/test_material_actions.py b/surfaces/blender/tests/test_material_actions.py new file mode 100644 index 0000000..9ecc2ec --- /dev/null +++ b/surfaces/blender/tests/test_material_actions.py @@ -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()