Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e279b2b0b | |||
|
|
f891404ee3 | ||
| 47d27271e7 | |||
|
|
01d25c6bf9 | ||
|
|
944ea4a1ec | ||
| 730ceb9e0e | |||
| 8cebec6f3c | |||
|
|
6b7d6ae9e2 | ||
|
|
ece40040f4 | ||
| 6995eabf51 | |||
| 6f0e70042d |
16 changed files with 3513 additions and 14 deletions
|
|
@ -244,6 +244,14 @@ fn build_provider_advertisement() -> ProviderAdvertisement {
|
||||||
"material.assign",
|
"material.assign",
|
||||||
"selection.read",
|
"selection.read",
|
||||||
"selection.write",
|
"selection.write",
|
||||||
|
"asset.import.gltf",
|
||||||
|
"asset.export.gltf",
|
||||||
|
"camera.write",
|
||||||
|
"light.write",
|
||||||
|
"world.write",
|
||||||
|
"render.still",
|
||||||
|
"nodegraph.geometry.write",
|
||||||
|
"geometry.evaluated.read",
|
||||||
"sync.session.publish",
|
"sync.session.publish",
|
||||||
"sync.object.bind",
|
"sync.object.bind",
|
||||||
"sync.var.publish",
|
"sync.var.publish",
|
||||||
|
|
@ -1985,6 +1993,18 @@ mod tests {
|
||||||
assert!(blender.capabilities.contains(&"object.graph.read"));
|
assert!(blender.capabilities.contains(&"object.graph.read"));
|
||||||
assert!(blender.capabilities.contains(&"object.transform.write"));
|
assert!(blender.capabilities.contains(&"object.transform.write"));
|
||||||
assert!(blender.capabilities.contains(&"material.assign"));
|
assert!(blender.capabilities.contains(&"material.assign"));
|
||||||
|
for scene_and_graph in [
|
||||||
|
"asset.import.gltf",
|
||||||
|
"asset.export.gltf",
|
||||||
|
"camera.write",
|
||||||
|
"light.write",
|
||||||
|
"world.write",
|
||||||
|
"render.still",
|
||||||
|
"nodegraph.geometry.write",
|
||||||
|
"geometry.evaluated.read",
|
||||||
|
] {
|
||||||
|
assert!(blender.capabilities.contains(&scene_and_graph), "{scene_and_graph}");
|
||||||
|
}
|
||||||
|
|
||||||
let route_sources: Vec<_> = provider
|
let route_sources: Vec<_> = provider
|
||||||
.realtime_routes
|
.realtime_routes
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,9 @@ All Blender writes use `brokkr.blender.command_intent.v0` and receive
|
||||||
- `setLight`
|
- `setLight`
|
||||||
- `setWorld`
|
- `setWorld`
|
||||||
- `renderStill`
|
- `renderStill`
|
||||||
|
- `createNodeGroup`, `addNode`, `removeNode`, `linkSockets`, `unlinkSockets`, `addModifier`,
|
||||||
|
`removeModifier`, `setModifierInput` (Geometry Nodes graphs; fields in
|
||||||
|
`surfaces/blender/docs/node-actions.md`)
|
||||||
|
|
||||||
Blender owns the mutation. Brokkr advertises the command surface; Verse clients
|
Blender owns the mutation. Brokkr advertises the command surface; Verse clients
|
||||||
write typed command intents; Blender executes recognized intents and publishes
|
write typed command intents; Blender executes recognized intents and publishes
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
from array import array
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from . import node_actions as na
|
||||||
from . import scene_actions as sa
|
from . import scene_actions as sa
|
||||||
from .server_guard import BindRefused, SessionGuard, check_bind, guarded_server_class, is_loopback_host, purge_command_intents
|
from .server_guard import BindRefused, SessionGuard, check_bind, guarded_server_class, is_loopback_host, purge_command_intents
|
||||||
|
|
||||||
|
|
@ -32,6 +36,17 @@ SYNC_VAR_SCHEMA = "brokkr.sync.var.v0"
|
||||||
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0"
|
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0"
|
||||||
SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0"
|
SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0"
|
||||||
|
|
||||||
|
CREATABLE_OBJECT_TYPES = ("MESH", "LIGHT", "CAMERA", "EMPTY")
|
||||||
|
|
||||||
|
# Engines that draw through the GL stack. A headless Linux Blender without libEGL aborts the whole
|
||||||
|
# process inside bpy.ops.render.render for these (no receipt); CYCLES renders on the CPU.
|
||||||
|
GL_RENDER_ENGINES = ("BLENDER_WORKBENCH", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
|
||||||
|
GL_RUNTIME_LIBRARY = "libEGL.so.1"
|
||||||
|
|
||||||
|
EVALUATED_POSITION_CAP = 256
|
||||||
|
EVALUATED_VERTEX_LIMIT = 1_000_000
|
||||||
|
EVALUATED_INSTANCE_SCAN_LIMIT = 100_000 # depsgraph items visited per capture; iterating one in Python costs ~5 us
|
||||||
|
|
||||||
CAPABILITIES = (
|
CAPABILITIES = (
|
||||||
"cultcache.mirror.publish",
|
"cultcache.mirror.publish",
|
||||||
"cultcache.intent.watch",
|
"cultcache.intent.watch",
|
||||||
|
|
@ -46,10 +61,13 @@ CAPABILITIES = (
|
||||||
"selection.read",
|
"selection.read",
|
||||||
"selection.write",
|
"selection.write",
|
||||||
"asset.import.gltf",
|
"asset.import.gltf",
|
||||||
|
"asset.export.gltf",
|
||||||
"camera.write",
|
"camera.write",
|
||||||
"light.write",
|
"light.write",
|
||||||
"world.write",
|
"world.write",
|
||||||
"render.still",
|
"render.still",
|
||||||
|
"nodegraph.geometry.write",
|
||||||
|
"geometry.evaluated.read",
|
||||||
"command.receipt.publish",
|
"command.receipt.publish",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -73,6 +91,7 @@ class BrokkrBlenderTarget:
|
||||||
self._debug_mirror_root: str = ""
|
self._debug_mirror_root: str = ""
|
||||||
self.import_dir: str = ""
|
self.import_dir: str = ""
|
||||||
self.output_dir: str = ""
|
self.output_dir: str = ""
|
||||||
|
self.nodes = na.NodeActions(bpy_module)
|
||||||
|
|
||||||
def configure_action_dirs(self, import_dir: str, output_dir: str) -> None:
|
def configure_action_dirs(self, import_dir: str, output_dir: str) -> None:
|
||||||
"""The directories importGltf and renderStill may touch; empty leaves that action disabled."""
|
"""The directories importGltf and renderStill may touch; empty leaves that action disabled."""
|
||||||
|
|
@ -441,6 +460,7 @@ class BrokkrBlenderTarget:
|
||||||
|
|
||||||
def capture_snapshot(self, context: Any) -> dict[str, Any]:
|
def capture_snapshot(self, context: Any) -> dict[str, Any]:
|
||||||
bpy = self.bpy
|
bpy = self.bpy
|
||||||
|
scan = self._instance_scan() if any(o.type == "MESH" and len(o.modifiers) for o in bpy.data.objects) else None
|
||||||
selected = [item.name for item in context.selected_objects if item is not None]
|
selected = [item.name for item in context.selected_objects if item is not None]
|
||||||
active_object = context.active_object.name if context.active_object else ""
|
active_object = context.active_object.name if context.active_object else ""
|
||||||
active_scene = context.scene.name if context.scene else ""
|
active_scene = context.scene.name if context.scene else ""
|
||||||
|
|
@ -463,7 +483,7 @@ class BrokkrBlenderTarget:
|
||||||
"imageCount": len(bpy.data.images),
|
"imageCount": len(bpy.data.images),
|
||||||
"capabilities": list(CAPABILITIES),
|
"capabilities": list(CAPABILITIES),
|
||||||
"scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes],
|
"scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes],
|
||||||
"objects": [self._object_snapshot(obj) for obj in bpy.data.objects],
|
"objects": [self._object_snapshot(obj, scan) for obj in bpy.data.objects],
|
||||||
"materials": [self._material_snapshot(material) for material in bpy.data.materials],
|
"materials": [self._material_snapshot(material) for material in bpy.data.materials],
|
||||||
"collections": [self._collection_snapshot(collection) for collection in bpy.data.collections],
|
"collections": [self._collection_snapshot(collection) for collection in bpy.data.collections],
|
||||||
}
|
}
|
||||||
|
|
@ -489,6 +509,8 @@ class BrokkrBlenderTarget:
|
||||||
return self._assign_material(context, command)
|
return self._assign_material(context, command)
|
||||||
if action == "importGltf":
|
if action == "importGltf":
|
||||||
return self._import_gltf(context, command)
|
return self._import_gltf(context, command)
|
||||||
|
if action == "exportGltf":
|
||||||
|
return self._export_gltf(context, command)
|
||||||
if action == "setCamera":
|
if action == "setCamera":
|
||||||
return self._set_camera(context, command)
|
return self._set_camera(context, command)
|
||||||
if action == "setLight":
|
if action == "setLight":
|
||||||
|
|
@ -497,6 +519,9 @@ class BrokkrBlenderTarget:
|
||||||
return self._set_world(context, command)
|
return self._set_world(context, command)
|
||||||
if action == "renderStill":
|
if action == "renderStill":
|
||||||
return self._render_still(context, command)
|
return self._render_still(context, command)
|
||||||
|
if action in na.NodeActions.ACTIONS:
|
||||||
|
message, name = self.nodes.execute(action, command)
|
||||||
|
return self._receipt(command, "accepted", message, name)
|
||||||
return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "")
|
return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "")
|
||||||
except sa.ActionRefused as error:
|
except sa.ActionRefused as error:
|
||||||
return self._receipt(command, "failed", str(error), "")
|
return self._receipt(command, "failed", str(error), "")
|
||||||
|
|
@ -505,6 +530,14 @@ class BrokkrBlenderTarget:
|
||||||
|
|
||||||
def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
||||||
object_type = command.get("objectType", "EMPTY")
|
object_type = command.get("objectType", "EMPTY")
|
||||||
|
if not isinstance(object_type, str) or object_type not in CREATABLE_OBJECT_TYPES:
|
||||||
|
shown = object_type if isinstance(object_type, str) else repr(object_type)
|
||||||
|
raise sa.ActionRefused(
|
||||||
|
f"objectType {shown!r} cannot be created; allowed: {', '.join(CREATABLE_OBJECT_TYPES)}.")
|
||||||
|
mesh_type = command.get("meshType")
|
||||||
|
if object_type == "MESH" and mesh_type is not None and (
|
||||||
|
not isinstance(mesh_type, str) or mesh_type.upper() != "CUBE"):
|
||||||
|
raise sa.ActionRefused(f"meshType {mesh_type!r} is not available; a MESH is a CUBE.")
|
||||||
name = command.get("name") or f"Brokkr {object_type.title()}"
|
name = command.get("name") or f"Brokkr {object_type.title()}"
|
||||||
location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0))
|
location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0))
|
||||||
|
|
||||||
|
|
@ -514,7 +547,7 @@ class BrokkrBlenderTarget:
|
||||||
self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location)
|
self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location)
|
||||||
elif object_type == "CAMERA":
|
elif object_type == "CAMERA":
|
||||||
self.bpy.ops.object.camera_add(location=location)
|
self.bpy.ops.object.camera_add(location=location)
|
||||||
else:
|
else: # EMPTY
|
||||||
self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location)
|
self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location)
|
||||||
|
|
||||||
obj = context.object
|
obj = context.object
|
||||||
|
|
@ -573,6 +606,10 @@ class BrokkrBlenderTarget:
|
||||||
property_name = command.get("propertyName") or "brokkrValue"
|
property_name = command.get("propertyName") or "brokkrValue"
|
||||||
if property_name.startswith("customProperties."):
|
if property_name.startswith("customProperties."):
|
||||||
property_name = property_name[len("customProperties."):]
|
property_name = property_name[len("customProperties."):]
|
||||||
|
modifier_name = na.drives_modifier_input(obj, property_name)
|
||||||
|
if modifier_name:
|
||||||
|
return self._receipt(command, "failed", f"{property_name!r} is an input of Geometry Nodes modifier {modifier_name!r}; "
|
||||||
|
"a custom property does not drive it, use setModifierInput.", obj.name)
|
||||||
obj[property_name] = command.get("value")
|
obj[property_name] = command.get("value")
|
||||||
return self._receipt(command, "accepted", "Blender custom property updated.", obj.name)
|
return self._receipt(command, "accepted", "Blender custom property updated.", obj.name)
|
||||||
|
|
||||||
|
|
@ -593,12 +630,22 @@ class BrokkrBlenderTarget:
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return self._receipt(command, "failed", "Target Blender object was not found.", "")
|
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_name = command.get("materialName") or "Brokkr Material"
|
||||||
material = self.bpy.data.materials.get(material_name) or self.bpy.data.materials.new(material_name)
|
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)
|
obj.data.materials.append(material)
|
||||||
return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
|
return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
|
||||||
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
|
|
||||||
|
|
||||||
def _import_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
def _import_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
||||||
name = str(command.get("filepath") or "")
|
name = str(command.get("filepath") or "")
|
||||||
|
|
@ -619,6 +666,61 @@ class BrokkrBlenderTarget:
|
||||||
added = sorted(set(self.bpy.data.objects.keys()) - before)
|
added = sorted(set(self.bpy.data.objects.keys()) - before)
|
||||||
return self._receipt(command, "accepted", f"Imported {len(added)} object(s) from {path.name}.", added[0] if added else "")
|
return self._receipt(command, "accepted", f"Imported {len(added)} object(s) from {path.name}.", added[0] if added else "")
|
||||||
|
|
||||||
|
def _export_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Write the scene (or the named objects) as .glb or .gltf under the output directory."""
|
||||||
|
name = command.get("filepath")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise sa.ActionRefused("filepath must be a string.")
|
||||||
|
path = sa.resolve_inside(self.output_dir, name, "output", new_file=True)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix not in sa.GLTF_EXTENSIONS:
|
||||||
|
raise sa.ActionRefused(f"Export path must end in {' or '.join(sa.GLTF_EXTENSIONS)}.")
|
||||||
|
options = {}
|
||||||
|
for field, key in (("extras", "export_extras"), ("yUp", "export_yup"), ("applyModifiers", "export_apply")):
|
||||||
|
value = command.get(field, True)
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise sa.ActionRefused(f"{field} must be true or false.")
|
||||||
|
options[key] = value
|
||||||
|
names = command.get("selection")
|
||||||
|
chosen = []
|
||||||
|
if names is not None:
|
||||||
|
if not isinstance(names, list) or not names or not all(isinstance(n, str) and n for n in names):
|
||||||
|
raise sa.ActionRefused("selection must be a non-empty list of object names.")
|
||||||
|
missing = [n for n in names if n not in self.bpy.data.objects]
|
||||||
|
if missing:
|
||||||
|
raise sa.ActionRefused(f"Selection object was not found: {missing[0]}")
|
||||||
|
chosen = [self.bpy.data.objects[n] for n in dict.fromkeys(names)]
|
||||||
|
# select_set raises for an object with no base in the view layer (another scene, an
|
||||||
|
# orphan, an excluded collection), and use_selection exports only selected layer objects.
|
||||||
|
off_layer = [o.name for o in chosen if o.name not in context.view_layer.objects]
|
||||||
|
if off_layer:
|
||||||
|
raise sa.ActionRefused(f"Selection object is not in the active view layer: {off_layer[0]}")
|
||||||
|
binary = path.with_suffix(".bin")
|
||||||
|
if suffix == ".gltf" and os.path.lexists(binary):
|
||||||
|
raise sa.ActionRefused("output path already exists; output actions never overwrite: " + binary.name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Re-check after the folders exist and just before use (residual TOCTOU as in renderStill).
|
||||||
|
if sa.resolve_inside(self.output_dir, name, "output", new_file=True) != path:
|
||||||
|
raise sa.ActionRefused("Output path changed while it was being checked.")
|
||||||
|
# The selection is touched only when one was asked for, and only on view-layer objects.
|
||||||
|
objects = list(context.view_layer.objects.values()) if chosen else []
|
||||||
|
saved = [(obj, obj.select_get()) for obj in objects]
|
||||||
|
try:
|
||||||
|
for obj in objects:
|
||||||
|
obj.select_set(obj in chosen)
|
||||||
|
result = self.bpy.ops.export_scene.gltf(
|
||||||
|
filepath=str(path), export_format="GLB" if suffix == ".glb" else "GLTF_SEPARATE",
|
||||||
|
use_selection=bool(chosen), **options)
|
||||||
|
finally:
|
||||||
|
for obj, state in saved:
|
||||||
|
if obj.name in self.bpy.data.objects:
|
||||||
|
obj.select_set(state)
|
||||||
|
if "FINISHED" not in result:
|
||||||
|
raise sa.ActionRefused(f"glTF export did not finish: {sorted(result)}")
|
||||||
|
if not path.is_file():
|
||||||
|
raise sa.ActionRefused("glTF export wrote no file.")
|
||||||
|
return self._receipt(command, "accepted", f"Exported glTF to {path} ({path.stat().st_size} bytes).", "")
|
||||||
|
|
||||||
def _resolve_typed(self, command: dict[str, Any], object_type: str) -> Any:
|
def _resolve_typed(self, command: dict[str, Any], object_type: str) -> Any:
|
||||||
obj = self._resolve_object(command)
|
obj = self._resolve_object(command)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
|
@ -724,6 +826,12 @@ class BrokkrBlenderTarget:
|
||||||
render.engine = engine
|
render.engine = engine
|
||||||
except TypeError:
|
except TypeError:
|
||||||
raise sa.ActionRefused(f"engine {engine} is not available in this Blender.") from None
|
raise sa.ActionRefused(f"engine {engine} is not available in this Blender.") from None
|
||||||
|
if render.engine in GL_RENDER_ENGINES and getattr(getattr(self.bpy, "app", None), "background", False):
|
||||||
|
missing = missing_gl_library()
|
||||||
|
if missing:
|
||||||
|
raise sa.ActionRefused(
|
||||||
|
f"engine {render.engine} needs {missing}, which this headless Blender cannot load "
|
||||||
|
"(rendering would abort the process); use engine CYCLES.")
|
||||||
if width is not None:
|
if width is not None:
|
||||||
render.resolution_x = width
|
render.resolution_x = width
|
||||||
if height is not None:
|
if height is not None:
|
||||||
|
|
@ -761,7 +869,7 @@ class BrokkrBlenderTarget:
|
||||||
"collectionName": scene.collection.name if scene.collection else "",
|
"collectionName": scene.collection.name if scene.collection else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _object_snapshot(self, obj: Any) -> dict[str, Any]:
|
def _object_snapshot(self, obj: Any, scan: Any = None) -> dict[str, Any]:
|
||||||
data = obj.data if hasattr(obj, "data") else None
|
data = obj.data if hasattr(obj, "data") else None
|
||||||
return {
|
return {
|
||||||
"name": obj.name,
|
"name": obj.name,
|
||||||
|
|
@ -780,6 +888,7 @@ class BrokkrBlenderTarget:
|
||||||
"constraints": [constraint.name for constraint in obj.constraints],
|
"constraints": [constraint.name for constraint in obj.constraints],
|
||||||
"camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None,
|
"camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None,
|
||||||
"mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None,
|
"mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None,
|
||||||
|
"evaluated": self._evaluated_snapshot(obj, scan),
|
||||||
"customProperties": self._custom_properties(obj.items()),
|
"customProperties": self._custom_properties(obj.items()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -801,6 +910,70 @@ class BrokkrBlenderTarget:
|
||||||
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
|
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _instance_scan(self) -> Any:
|
||||||
|
"""One pass over the depsgraph's instances: per original parent object, the count, the first locations
|
||||||
|
and the running bounds (world space). Nothing is kept per instance beyond the cap."""
|
||||||
|
depsgraph = self.bpy.context.evaluated_depsgraph_get()
|
||||||
|
depsgraph.update()
|
||||||
|
found: dict[str, dict[str, Any]] = {}
|
||||||
|
visited = 0
|
||||||
|
complete = True
|
||||||
|
for item in depsgraph.object_instances:
|
||||||
|
visited += 1
|
||||||
|
if visited > EVALUATED_INSTANCE_SCAN_LIMIT:
|
||||||
|
complete = False
|
||||||
|
break
|
||||||
|
if not item.is_instance or item.parent is None:
|
||||||
|
continue
|
||||||
|
entry = found.setdefault(item.parent.original.name, {"count": 0, "first": [], "min": None, "max": None})
|
||||||
|
point = tuple(float(c) for c in item.matrix_world.translation)
|
||||||
|
entry["count"] += 1
|
||||||
|
if len(entry["first"]) < EVALUATED_POSITION_CAP:
|
||||||
|
entry["first"].append([round(c, 5) for c in point])
|
||||||
|
entry["min"] = list(point) if entry["min"] is None else [min(a, b) for a, b in zip(entry["min"], point)]
|
||||||
|
entry["max"] = list(point) if entry["max"] is None else [max(a, b) for a, b in zip(entry["max"], point)]
|
||||||
|
return depsgraph, found, complete
|
||||||
|
|
||||||
|
def _evaluated_snapshot(self, obj: Any, scan: Any) -> dict[str, Any] | None:
|
||||||
|
"""What the modifier stack produced, for mesh objects that have one. Vertex positions and their bounds
|
||||||
|
are in the object's local space; instance locations and their bounds are world space. A Geometry Nodes
|
||||||
|
object's own mesh is empty, so the base mesh counts say nothing about it. Above EVALUATED_VERTEX_LIMIT
|
||||||
|
vertices only the counts are read, and any non-finite coordinate drops the positions and sets nonFinite."""
|
||||||
|
if obj.type != "MESH" or not len(obj.modifiers) or scan is None:
|
||||||
|
return None
|
||||||
|
depsgraph, found, complete = scan
|
||||||
|
evaluated = obj.evaluated_get(depsgraph)
|
||||||
|
mesh = evaluated.to_mesh()
|
||||||
|
try:
|
||||||
|
count = len(mesh.vertices)
|
||||||
|
result = {"vertexCount": count, "edgeCount": len(mesh.edges), "polygonCount": len(mesh.polygons)}
|
||||||
|
coords = array("f", bytes(12 * count)) if 0 < count <= EVALUATED_VERTEX_LIMIT else None
|
||||||
|
if coords is not None:
|
||||||
|
mesh.vertices.foreach_get("co", coords)
|
||||||
|
finally:
|
||||||
|
evaluated.to_mesh_clear()
|
||||||
|
entry = found.get(obj.name)
|
||||||
|
reached = entry is not None
|
||||||
|
entry = entry or {"count": 0, "first": [], "min": [], "max": []}
|
||||||
|
finite = all(math.isfinite(c) for c in coords) if coords is not None else True
|
||||||
|
finite = finite and all(math.isfinite(c) for c in (entry["min"] or []) + (entry["max"] or []) + [c for p in entry["first"] for c in p])
|
||||||
|
points = [coords[i:i + 3] for i in range(0, 3 * count, 3)] if coords is not None else []
|
||||||
|
result.update({
|
||||||
|
# An object the stopped scan never reached has an unknown count, not zero.
|
||||||
|
"instanceCount": entry["count"] if complete or reached else None,
|
||||||
|
"instancesComplete": complete,
|
||||||
|
"positionsSkipped": count > EVALUATED_VERTEX_LIMIT,
|
||||||
|
"nonFinite": not finite,
|
||||||
|
"vertexBoundsMin": [min(p[a] for p in points) for a in range(3)] if points and finite else [],
|
||||||
|
"vertexBoundsMax": [max(p[a] for p in points) for a in range(3)] if points and finite else [],
|
||||||
|
"vertexLocations": [[round(c, 5) for c in p] for p in points[:EVALUATED_POSITION_CAP]] if finite else [],
|
||||||
|
"instanceBoundsMin": entry["min"] if finite and entry["min"] else [],
|
||||||
|
"instanceBoundsMax": entry["max"] if finite and entry["max"] else [],
|
||||||
|
"instanceLocations": entry["first"] if finite else [],
|
||||||
|
"truncated": count > EVALUATED_POSITION_CAP or entry["count"] > EVALUATED_POSITION_CAP,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
|
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"name": modifier.name,
|
"name": modifier.name,
|
||||||
|
|
@ -941,6 +1114,19 @@ def _load_cultmesh(cultlib_py_src: str) -> tuple[Any, Any]:
|
||||||
return cultmesh_py, cultcache_py
|
return cultmesh_py, cultcache_py
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def missing_gl_library() -> str | None:
|
||||||
|
"""The GL runtime library this host cannot load, or None. Probed once, on Linux only: on
|
||||||
|
Windows and macOS a failed GPU render is not a headless-EGL abort."""
|
||||||
|
if not sys.platform.startswith("linux"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ctypes.CDLL(GL_RUNTIME_LIBRARY)
|
||||||
|
except OSError:
|
||||||
|
return GL_RUNTIME_LIBRARY
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
|
||||||
375
surfaces/blender/brokkr_bridge/node_actions.py
Normal file
375
surfaces/blender/brokkr_bridge/node_actions.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
"""Geometry node graph command actions: createNodeGroup, addNode, linkSockets, addModifier, setModifierInput.
|
||||||
|
|
||||||
|
Owner of how an intent becomes a node group, a node, a link or a modifier input on Blender 5.2.
|
||||||
|
Validation is plain Python and runs before bpy is touched; apply steps that can still fail after
|
||||||
|
Blender has been asked (an unknown node type, an enum that is not offered) undo what they made, so a
|
||||||
|
refused intent leaves the tree as it was. Docs: docs/node-actions.md. The design is PR #3 (branch libby/blender-node-graphs-design).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .scene_actions import ActionRefused
|
||||||
|
|
||||||
|
ID_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,63}")
|
||||||
|
KEY_PATTERN = re.compile(r"[A-Za-z0-9 ._-]{1,63}")
|
||||||
|
# A geometry tree may hold geometry, function and shader math nodes. Nodes that read or write files,
|
||||||
|
# run code, write caches, nest groups or sample images are not part of this cut.
|
||||||
|
NODE_IDNAME = re.compile(r"(GeometryNode|FunctionNode|ShaderNode)[A-Za-z0-9]+")
|
||||||
|
NODE_DENIED = ("Import", "Export", "Script", "Bake", "Group", "Simulation", "Repeat", "Foreach", "Viewer", "TexImage", "TexEnvironment",
|
||||||
|
"ImageTexture", "InputImage", "TexIES")
|
||||||
|
MAX_NODES = 500
|
||||||
|
MAX_INTERFACE = 64
|
||||||
|
MAX_MODIFIERS = 8
|
||||||
|
MAX_VALUE = 1.0e9
|
||||||
|
MAX_INT = 4096 # counts, levels and sizes multiply: a 4096 x 4096 grid is the ceiling
|
||||||
|
SOCKET_TYPES = {"Geometry": "NodeSocketGeometry", "Float": "NodeSocketFloat", "Int": "NodeSocketInt",
|
||||||
|
"Bool": "NodeSocketBool", "Vector": "NodeSocketVector"}
|
||||||
|
IN_OUT = {"INPUT", "OUTPUT"}
|
||||||
|
# Node properties that are not node settings, or that make a node reach outside the tree.
|
||||||
|
NODE_PROPERTY_DENIED = frozenset({"name", "label", "location", "width", "height", "parent", "node_tree", "select",
|
||||||
|
"mute", "hide", "show_options", "show_preview", "show_texture", "use_custom_color",
|
||||||
|
"color", "bl_idname", "bl_label", "bl_description", "bl_icon", "bl_static_type",
|
||||||
|
"bl_width_default", "bl_width_min", "bl_width_max", "bl_height_default",
|
||||||
|
"bl_height_min", "bl_height_max", "rna_type", "type", "internal_links"})
|
||||||
|
SETTABLE_PROPERTY_TYPES = ("ENUM", "BOOLEAN", "INT", "FLOAT")
|
||||||
|
|
||||||
|
|
||||||
|
def text(command: dict[str, Any], key: str, pattern: re.Pattern, label: str, *, required: bool = True) -> str:
|
||||||
|
value = command.get(key)
|
||||||
|
if value is None or value == "":
|
||||||
|
if required:
|
||||||
|
raise ActionRefused(f"{label} is required.")
|
||||||
|
return ""
|
||||||
|
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||||
|
raise ActionRefused(f"{label} must be 1..63 characters of letters, digits, space, '.', '_' or '-'.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def tree_key(command: dict[str, Any]) -> str:
|
||||||
|
kind = command.get("treeKind", "geometry")
|
||||||
|
if kind != "geometry":
|
||||||
|
raise ActionRefused(f"treeKind {kind!r} is not supported; only 'geometry'.")
|
||||||
|
return text(command, "treeKey", KEY_PATTERN, "treeKey")
|
||||||
|
|
||||||
|
|
||||||
|
def finite(value: Any, label: str) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or abs(value) > MAX_VALUE:
|
||||||
|
raise ActionRefused(f"{label} must be a finite number within +-{MAX_VALUE:g}.")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def typed_value(value: Any, kind: str, label: str) -> Any:
|
||||||
|
"""A value for a socket of `kind` (Blender's socket.type, or the interface type's tail), or ActionRefused."""
|
||||||
|
if kind in ("VALUE", "FLOAT", "Float"):
|
||||||
|
return finite(value, label)
|
||||||
|
if kind in ("INT", "Int"):
|
||||||
|
number = finite(value, label)
|
||||||
|
if number != int(number) or abs(number) > MAX_INT:
|
||||||
|
raise ActionRefused(f"{label} must be a whole number within +-{MAX_INT}.")
|
||||||
|
return int(number)
|
||||||
|
if kind in ("BOOLEAN", "Bool"):
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ActionRefused(f"{label} must be true or false.")
|
||||||
|
return value
|
||||||
|
if kind in ("VECTOR", "Vector", "RGBA"):
|
||||||
|
size = 4 if kind == "RGBA" else 3
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != size:
|
||||||
|
raise ActionRefused(f"{label} must be exactly {size} numbers.")
|
||||||
|
return tuple(finite(part, f"{label}[{i}]") for i, part in enumerate(value))
|
||||||
|
raise ActionRefused(f"{label}: socket type {kind} cannot be set by an intent.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_idname(idname: Any) -> str:
|
||||||
|
if not isinstance(idname, str) or not NODE_IDNAME.fullmatch(idname):
|
||||||
|
raise ActionRefused("blIdname must be a GeometryNode*, FunctionNode* or ShaderNode* type.")
|
||||||
|
for denied in NODE_DENIED:
|
||||||
|
if denied in idname:
|
||||||
|
raise ActionRefused(f"node type {idname} is not allowed in this cut ({denied}).")
|
||||||
|
return idname
|
||||||
|
|
||||||
|
|
||||||
|
def check_interface(items: Any) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(items, list) or not items or len(items) > MAX_INTERFACE:
|
||||||
|
raise ActionRefused(f"interface must be a list of 1..{MAX_INTERFACE} sockets.")
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
result = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ActionRefused(f"interface[{i}] must be an object.")
|
||||||
|
in_out = str(item.get("inOut", "")).upper()
|
||||||
|
name = item.get("name")
|
||||||
|
socket_type = item.get("type")
|
||||||
|
if in_out not in IN_OUT or not isinstance(name, str) or not name or len(name) > 63 or socket_type not in SOCKET_TYPES:
|
||||||
|
raise ActionRefused(f"interface[{i}] needs inOut INPUT|OUTPUT, a name and a type of {sorted(SOCKET_TYPES)}.")
|
||||||
|
if (in_out, name) in seen:
|
||||||
|
raise ActionRefused(f"interface[{i}] repeats {in_out} socket {name!r}.")
|
||||||
|
seen.add((in_out, name))
|
||||||
|
entry = {"inOut": in_out, "name": name, "type": socket_type}
|
||||||
|
if "default" in item:
|
||||||
|
entry["default"] = typed_value(item["default"], socket_type, f"interface[{i}].default")
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class NodeActions:
|
||||||
|
"""Applies the node-graph actions to a bpy module. Stateless: every lookup goes to bpy.data."""
|
||||||
|
|
||||||
|
ACTIONS = ("createNodeGroup", "addNode", "removeNode", "linkSockets", "unlinkSockets", "addModifier", "removeModifier", "setModifierInput")
|
||||||
|
|
||||||
|
def __init__(self, bpy_module: Any):
|
||||||
|
self.bpy = bpy_module
|
||||||
|
|
||||||
|
def execute(self, action: str, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
return getattr(self, "_" + action)(command)
|
||||||
|
|
||||||
|
# tree and node lookup
|
||||||
|
def _tree(self, key: str) -> Any:
|
||||||
|
tree = self.bpy.data.node_groups.get(key)
|
||||||
|
if tree is None:
|
||||||
|
raise ActionRefused(f"Node group {key!r} does not exist; createNodeGroup first.")
|
||||||
|
if tree.bl_idname != "GeometryNodeTree":
|
||||||
|
raise ActionRefused(f"Node group {key!r} is not a geometry node group.")
|
||||||
|
if getattr(tree, "library", None):
|
||||||
|
raise ActionRefused(f"Node group {key!r} is linked from a library and read-only.")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _node(tree: Any, node_id: str) -> Any:
|
||||||
|
for node in tree.nodes:
|
||||||
|
if node.get("brokkr_id") == node_id:
|
||||||
|
return node
|
||||||
|
raise ActionRefused(f"Node {node_id!r} does not exist in {tree.name!r}.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _socket(sockets: Any, key: str, label: str) -> Any:
|
||||||
|
"""A socket of a node by identifier, else by display name when exactly one available socket has it."""
|
||||||
|
usable = [s for s in sockets if s.identifier and getattr(s, "enabled", True)]
|
||||||
|
for socket in usable:
|
||||||
|
if socket.identifier == key:
|
||||||
|
return socket
|
||||||
|
named = [s for s in usable if s.name == key]
|
||||||
|
if len(named) == 1:
|
||||||
|
return named[0]
|
||||||
|
raise ActionRefused(f"{label}: socket {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
|
||||||
|
|
||||||
|
# actions
|
||||||
|
def _createNodeGroup(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
key = tree_key(command)
|
||||||
|
interface = check_interface(command.get("interface"))
|
||||||
|
existing = self.bpy.data.node_groups.get(key)
|
||||||
|
if existing is not None:
|
||||||
|
raise ActionRefused(f"Node group {key!r} already exists.")
|
||||||
|
tree = self.bpy.data.node_groups.new(key, "GeometryNodeTree")
|
||||||
|
try:
|
||||||
|
made = []
|
||||||
|
for entry in interface:
|
||||||
|
socket = tree.interface.new_socket(entry["name"], in_out=entry["inOut"], socket_type=SOCKET_TYPES[entry["type"]])
|
||||||
|
if "default" in entry:
|
||||||
|
socket.default_value = entry["default"]
|
||||||
|
made.append(f"{entry['inOut'].lower()} {entry['name']}={socket.identifier}")
|
||||||
|
for node_id, idname in (("gin", "NodeGroupInput"), ("gout", "NodeGroupOutput")):
|
||||||
|
node = tree.nodes.new(idname)
|
||||||
|
node["brokkr_id"] = node_id
|
||||||
|
except Exception:
|
||||||
|
self.bpy.data.node_groups.remove(tree)
|
||||||
|
raise
|
||||||
|
return "Created node group; interface identifiers: " + ", ".join(made), tree.name
|
||||||
|
|
||||||
|
def _addNode(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
tree = self._tree(tree_key(command))
|
||||||
|
node_id = text(command, "id", ID_PATTERN, "id")
|
||||||
|
idname = check_idname(command.get("blIdname"))
|
||||||
|
location = command.get("location", [0, 0])
|
||||||
|
if not isinstance(location, (list, tuple)) or len(location) != 2:
|
||||||
|
raise ActionRefused("location must be two numbers.")
|
||||||
|
location = [finite(part, "location") for part in location]
|
||||||
|
properties = command.get("properties") or {}
|
||||||
|
inputs = command.get("inputs") or {}
|
||||||
|
if not isinstance(properties, dict) or not isinstance(inputs, dict):
|
||||||
|
raise ActionRefused("properties and inputs must be objects.")
|
||||||
|
if any(n.get("brokkr_id") == node_id for n in tree.nodes):
|
||||||
|
raise ActionRefused(f"Node id {node_id!r} already exists in {tree.name!r}.")
|
||||||
|
if len(tree.nodes) >= MAX_NODES:
|
||||||
|
raise ActionRefused(f"Node group {tree.name!r} already has {MAX_NODES} nodes.")
|
||||||
|
try:
|
||||||
|
node = tree.nodes.new(idname)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise ActionRefused(f"Blender does not offer node type {idname}: {error}") from None
|
||||||
|
try:
|
||||||
|
node["brokkr_id"] = node_id
|
||||||
|
node.location = location
|
||||||
|
if command.get("label"):
|
||||||
|
node.label = str(command["label"])[:63]
|
||||||
|
for name, value in properties.items(): # node settings first: they decide which sockets exist
|
||||||
|
self._set_property(node, name, value)
|
||||||
|
for name, value in inputs.items():
|
||||||
|
socket = self._socket(node.inputs, name, f"input {name!r}")
|
||||||
|
if socket.is_linked or not hasattr(socket, "default_value"):
|
||||||
|
raise ActionRefused(f"input {name!r} has no settable default.")
|
||||||
|
socket.default_value = typed_value(value, socket.type, f"input {name!r}")
|
||||||
|
except Exception:
|
||||||
|
tree.nodes.remove(node)
|
||||||
|
raise
|
||||||
|
return "Node added.", node_id
|
||||||
|
|
||||||
|
def _set_property(self, node: Any, name: str, value: Any) -> None:
|
||||||
|
prop = node.bl_rna.properties.get(name) if isinstance(name, str) else None
|
||||||
|
if prop is None or prop.is_readonly or name in NODE_PROPERTY_DENIED or prop.type not in SETTABLE_PROPERTY_TYPES:
|
||||||
|
raise ActionRefused(f"{node.bl_idname} has no settable property {name!r}.")
|
||||||
|
if prop.type == "ENUM":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ActionRefused(f"property {name!r} needs an enum identifier.")
|
||||||
|
else:
|
||||||
|
value = typed_value(value, {"BOOLEAN": "BOOLEAN", "INT": "INT", "FLOAT": "FLOAT"}[prop.type], f"property {name!r}")
|
||||||
|
try:
|
||||||
|
setattr(node, name, value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ActionRefused(f"property {name!r} cannot be {value!r}: {error}") from None
|
||||||
|
|
||||||
|
def _removeNode(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
tree = self._tree(tree_key(command))
|
||||||
|
node_id = text(command, "id", ID_PATTERN, "id")
|
||||||
|
if node_id in ("gin", "gout"):
|
||||||
|
raise ActionRefused(f"{node_id!r} is the group's own input/output node and stays.")
|
||||||
|
node = next((n for n in tree.nodes if n.get("brokkr_id") == node_id), None)
|
||||||
|
if node is None:
|
||||||
|
return f"Node {node_id!r} was already absent.", node_id
|
||||||
|
tree.nodes.remove(node) # Blender drops the node's links with it
|
||||||
|
return "Node removed.", node_id
|
||||||
|
|
||||||
|
def _unlinkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
tree = self._tree(tree_key(command))
|
||||||
|
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
|
||||||
|
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
|
||||||
|
source = out = None
|
||||||
|
if command.get("fromNode") or command.get("fromSocket"):
|
||||||
|
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
|
||||||
|
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
|
||||||
|
doomed = [l for l in tree.links if l.to_socket == into and (out is None or l.from_socket == out)]
|
||||||
|
for link in doomed:
|
||||||
|
tree.links.remove(link)
|
||||||
|
return f"Removed {len(doomed)} link(s).", tree.name
|
||||||
|
|
||||||
|
def _linkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
tree = self._tree(tree_key(command))
|
||||||
|
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
|
||||||
|
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
|
||||||
|
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
|
||||||
|
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
|
||||||
|
if reaches(tree, target, source): # includes a self link
|
||||||
|
raise ActionRefused(f"Link {source.get('brokkr_id')} -> {target.get('brokkr_id')} would make a cycle.")
|
||||||
|
displaced = [(l.from_socket, l.is_muted) for l in tree.links if l.to_socket == into] if not getattr(into, "is_multi_input", False) else []
|
||||||
|
link = tree.links.new(out, into) # replaces whatever single link the input had
|
||||||
|
if not link.is_valid:
|
||||||
|
tree.links.remove(link)
|
||||||
|
for old, muted in displaced:
|
||||||
|
tree.links.new(old, into).is_muted = muted
|
||||||
|
raise ActionRefused(f"Link {out.name} -> {into.name} is not valid (type or field/single-value mismatch).")
|
||||||
|
return f"Linked {source.get('brokkr_id')}.{out.identifier} -> {target.get('brokkr_id')}.{into.identifier}.", tree.name
|
||||||
|
|
||||||
|
def _object(self, command: dict[str, Any]) -> Any:
|
||||||
|
name = command.get("objectName") or command.get("targetObjectName")
|
||||||
|
obj = self.bpy.data.objects.get(name) if name else None
|
||||||
|
if obj is None:
|
||||||
|
raise ActionRefused(f"Object {name!r} was not found.")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _addModifier(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
obj = self._object(command)
|
||||||
|
kind = command.get("modifierType", "NODES")
|
||||||
|
if kind != "NODES":
|
||||||
|
raise ActionRefused("modifierType must be NODES; other modifiers are not part of this cut.")
|
||||||
|
tree = self._tree(tree_key(command))
|
||||||
|
name = text(command, "modifierName", KEY_PATTERN, "modifierName", required=False) or "Brokkr GeoNodes"
|
||||||
|
modifier = obj.modifiers.get(name)
|
||||||
|
if modifier is not None and modifier.type != "NODES":
|
||||||
|
raise ActionRefused(f"{obj.name!r} already has a non-Geometry-Nodes modifier named {name!r}.")
|
||||||
|
if modifier is None:
|
||||||
|
if len(obj.modifiers) >= MAX_MODIFIERS:
|
||||||
|
raise ActionRefused(f"{obj.name!r} already has {MAX_MODIFIERS} modifiers.")
|
||||||
|
modifier = obj.modifiers.new(name, "NODES")
|
||||||
|
if modifier is None:
|
||||||
|
raise ActionRefused(f"{obj.name!r} is a {obj.type} object; it cannot take a Geometry Nodes modifier.")
|
||||||
|
modifier.node_group = tree
|
||||||
|
obj.update_tag()
|
||||||
|
return "Geometry Nodes modifier set.", obj.name
|
||||||
|
|
||||||
|
def _removeModifier(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
obj = self._object(command)
|
||||||
|
# A lookup, not a creation: a modifier made by hand may have any name Blender allows.
|
||||||
|
name = command.get("modifierName") or "Brokkr GeoNodes"
|
||||||
|
if not isinstance(name, str) or len(name) > 255 or "\0" in name:
|
||||||
|
raise ActionRefused("modifierName must be a string of at most 255 characters.")
|
||||||
|
modifier = obj.modifiers.get(name)
|
||||||
|
if modifier is None:
|
||||||
|
return f"Modifier {name!r} was already absent.", obj.name
|
||||||
|
if modifier.type != "NODES":
|
||||||
|
raise ActionRefused(f"{name!r} is not a Geometry Nodes modifier; only those are removed here.")
|
||||||
|
obj.modifiers.remove(modifier)
|
||||||
|
obj.update_tag()
|
||||||
|
return "Modifier removed.", obj.name
|
||||||
|
|
||||||
|
def _setModifierInput(self, command: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
obj = self._object(command)
|
||||||
|
modifier = obj.modifiers.get(str(command.get("modifierName") or "Brokkr GeoNodes"))
|
||||||
|
if modifier is None or modifier.type != "NODES" or modifier.node_group is None:
|
||||||
|
raise ActionRefused(f"{obj.name!r} has no Geometry Nodes modifier with a node group of that name.")
|
||||||
|
item = modifier_input(modifier.node_group, str(command.get("socket", "")))
|
||||||
|
value = typed_value(command.get("value"), item.socket_type.replace("NodeSocket", ""), f"input {item.name!r}")
|
||||||
|
# modifier.properties.inputs[id] is a raw IDPropertyGroup on 5.2.3; attribute access reaches the struct.
|
||||||
|
getattr(modifier.properties.inputs, item.identifier).value = value
|
||||||
|
obj.update_tag() # without it a headless re-evaluation returned the previous result
|
||||||
|
return f"Modifier input {item.name} set.", obj.name
|
||||||
|
|
||||||
|
|
||||||
|
def node_key(node: Any) -> Any:
|
||||||
|
"""A stable identity for a node: bpy hands out a fresh wrapper per access, so id() of one is reused by the next."""
|
||||||
|
return node.as_pointer() if hasattr(node, "as_pointer") else id(node)
|
||||||
|
|
||||||
|
|
||||||
|
def reaches(tree: Any, start: Any, goal: Any) -> bool:
|
||||||
|
"""True if following links forward from `start` arrives at `goal` (so a link goal -> start closes a cycle).
|
||||||
|
The adjacency is built once per call, so a call costs O(links)."""
|
||||||
|
forward: dict[Any, list[Any]] = {}
|
||||||
|
for link in tree.links:
|
||||||
|
forward.setdefault(node_key(link.from_node), []).append(link.to_node)
|
||||||
|
target = node_key(goal)
|
||||||
|
seen: set[Any] = set()
|
||||||
|
stack = [start]
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
key = node_key(node)
|
||||||
|
if key == target:
|
||||||
|
return True
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
stack.extend(forward.get(key, ()))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def modifier_input(tree: Any, key: str) -> Any:
|
||||||
|
"""An INPUT socket of a group's interface, by identifier or by its unique name."""
|
||||||
|
items = [i for i in tree.interface.items_tree if i.item_type == "SOCKET" and i.in_out == "INPUT"]
|
||||||
|
for item in items:
|
||||||
|
if item.identifier == key:
|
||||||
|
return item
|
||||||
|
named = [i for i in items if i.name == key]
|
||||||
|
if len(named) == 1:
|
||||||
|
return named[0]
|
||||||
|
raise ActionRefused(f"Interface input {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
|
||||||
|
|
||||||
|
|
||||||
|
def drives_modifier_input(obj: Any, property_name: str) -> str:
|
||||||
|
"""The modifier whose group has an interface input with identifier property_name (Socket_N), else ''.
|
||||||
|
An object custom property never drives a modifier input, so only the modifier's own identifiers are
|
||||||
|
refused; a property that merely shares an input's display name is an ordinary custom property."""
|
||||||
|
for modifier in obj.modifiers:
|
||||||
|
if modifier.type == "NODES" and modifier.node_group is not None:
|
||||||
|
for item in modifier.node_group.interface.items_tree:
|
||||||
|
if item.item_type == "SOCKET" and item.in_out == "INPUT" and property_name == item.identifier:
|
||||||
|
return modifier.name
|
||||||
|
return ""
|
||||||
|
|
@ -98,7 +98,7 @@ def resolve_inside(root: str, candidate: str, label: str, *, new_file: bool = Fa
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ActionRefused(f"{label} path is outside the configured {label} directory.") from None
|
raise ActionRefused(f"{label} path is outside the configured {label} directory.") from None
|
||||||
if new_file and os.path.lexists(current):
|
if new_file and os.path.lexists(current):
|
||||||
raise ActionRefused(f"{label} path already exists; renderStill never overwrites or writes through an existing name.")
|
raise ActionRefused(f"{label} path already exists; output actions never overwrite or write through an existing name.")
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,6 +187,15 @@ def integer(command: dict[str, Any], key: str, low: int, high: int) -> int | Non
|
||||||
return int(value)
|
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:
|
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."""
|
"""Linear RGB, three finite numbers in [0, 1], or None when absent."""
|
||||||
if key not in command or command[key] is None:
|
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].")
|
raise ActionRefused(f"{key} must be three numbers [r, g, b].")
|
||||||
parts = []
|
parts = []
|
||||||
for item in value:
|
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:
|
parts.append(unit_component(key, item))
|
||||||
raise ActionRefused(f"{key} components must be numbers between 0 and 1.")
|
|
||||||
parts.append(float(item))
|
|
||||||
return (parts[0], parts[1], parts[2])
|
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:
|
def choice(command: dict[str, Any], key: str, allowed: Iterable[str]) -> str | None:
|
||||||
if key not in command or command[key] is None:
|
if key not in command or command[key] is None:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ Admitted command actions:
|
||||||
- `selectObject`
|
- `selectObject`
|
||||||
- `assignMaterial`
|
- `assignMaterial`
|
||||||
- `importGltf`
|
- `importGltf`
|
||||||
|
- `exportGltf`
|
||||||
- `setCamera`
|
- `setCamera`
|
||||||
- `setLight`
|
- `setLight`
|
||||||
- `setWorld`
|
- `setWorld`
|
||||||
|
|
@ -116,9 +117,24 @@ Admitted command actions:
|
||||||
Scene action fields (all other keys are ignored; a refused intent changes nothing and
|
Scene action fields (all other keys are ignored; a refused intent changes nothing and
|
||||||
gets a `failed` receipt whose message names the rule):
|
gets a `failed` receipt whose message names the rule):
|
||||||
|
|
||||||
|
- `createObject`: optional `objectType` (`MESH`, `LIGHT`, `CAMERA`, `EMPTY`; default `EMPTY`),
|
||||||
|
`name`, `location`. Any other `objectType` (for example `TEXT` or `CURVE`) is refused with
|
||||||
|
the allowed set named. A `MESH` is a cube (`size`); a `meshType` other than `CUBE`
|
||||||
|
(case-insensitive, no surrounding whitespace) is refused rather than ignored.
|
||||||
|
`meshType` is checked only when `objectType` is `MESH`; an explicit `objectType: null` is refused.
|
||||||
- `importGltf`: `filepath`, `.gltf` or `.glb`, relative to the add-on preference
|
- `importGltf`: `filepath`, `.gltf` or `.glb`, relative to the add-on preference
|
||||||
`Import Directory` (absolute paths, `..` and symlinks that resolve outside it are
|
`Import Directory` (absolute paths, `..` and symlinks that resolve outside it are
|
||||||
refused). Empty `Import Directory` disables the action. Needs Blender's glTF add-on.
|
refused). Empty `Import Directory` disables the action. Needs Blender's glTF add-on.
|
||||||
|
- `exportGltf`: `filepath`, `.glb` (binary) or `.gltf` (separate `.bin`), relative to the
|
||||||
|
add-on preference `Render Output Directory` (the renderStill rules: no `..`, no links, and an
|
||||||
|
existing name, or an existing `.bin` beside a `.gltf`, is refused; there is no overwrite).
|
||||||
|
Optional `extras` (custom properties become node `extras`), `yUp`, `applyModifiers`
|
||||||
|
(each true by default, must be a boolean) and `selection` (a non-empty list of object
|
||||||
|
names; any name not found, or not in the active view layer, refuses the export; the
|
||||||
|
selection is touched only when one is given, and restored afterwards). Not checked: texture
|
||||||
|
files a `.gltf` export writes beside it (only the `.bin` is), and a link or swap made between the
|
||||||
|
check and the write (the same residual as renderStill).
|
||||||
|
The receipt message carries the written path and byte size. Needs Blender's glTF add-on.
|
||||||
- `setCamera`: `targetObjectName` (a camera object); optional `lens` (mm),
|
- `setCamera`: `targetObjectName` (a camera object); optional `lens` (mm),
|
||||||
`cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example
|
`cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example
|
||||||
`EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2),
|
`EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2),
|
||||||
|
|
@ -128,12 +144,26 @@ gets a `failed` receipt whose message names the rule):
|
||||||
`color` (linear `[r, g, b]`, each 0 to 1), `lightType` (`POINT`, `SUN`, `SPOT`, `AREA`).
|
`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
|
- `setWorld`: optional `worldName` (default: the scene's world, created if there is
|
||||||
none), `color` (`[r, g, b]`), `strength`. Edits the world's Background node.
|
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`,
|
- `renderStill`: `outputPath` relative to the preference `Render Output Directory`,
|
||||||
ending `.png`, `.jpg` or `.exr` (same containment rules as import); optional
|
ending `.png`, `.jpg` or `.exr` (same containment rules as import); optional
|
||||||
`engine` (any engine of the running Blender, case-insensitive), `resolutionX`,
|
`engine` (any engine of the running Blender, case-insensitive), `resolutionX`,
|
||||||
`resolutionY` (whole numbers, 1 to 16384), `cameraName`. Engine, resolution,
|
`resolutionY` (whole numbers, 1 to 16384), `cameraName`. Engine, resolution,
|
||||||
camera, file path and format are restored after the render. The render blocks
|
camera, file path and format are restored after the render. The render blocks
|
||||||
Blender's UI until it finishes.
|
Blender's UI until it finishes. In a background (`-b`) Blender on Linux, the GL
|
||||||
|
engines `BLENDER_WORKBENCH`, `BLENDER_EEVEE` and `BLENDER_EEVEE_NEXT` are refused
|
||||||
|
with a `failed` receipt when `libEGL.so.1` cannot be loaded (probed once and cached for the life of the process), because
|
||||||
|
rendering would abort the whole process with no receipt; `CYCLES` renders on the
|
||||||
|
CPU. The check applies to the scene's engine when `engine` is omitted. No such
|
||||||
|
check runs on Windows or macOS.
|
||||||
|
|
||||||
The scene actions were run against Blender 5.2.3 LTS headless with
|
The scene actions were run against Blender 5.2.3 LTS headless with
|
||||||
`surfaces/blender/tests/live/headless_smoke.py` (usage in its docstring). One real
|
`surfaces/blender/tests/live/headless_smoke.py` (usage in its docstring). One real
|
||||||
|
|
|
||||||
39
surfaces/blender/docs/node-actions.md
Normal file
39
surfaces/blender/docs/node-actions.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Geometry node graph actions
|
||||||
|
|
||||||
|
Command intents (`brokkr.blender.command_intent.v0`) that build and drive a Geometry Nodes graph.
|
||||||
|
Owner: `brokkr_bridge/node_actions.py`; the target only dispatches. Every field is validated before
|
||||||
|
`bpy` is touched; a refused intent is a `failed` receipt naming the rule and leaves the tree as it was.
|
||||||
|
Design and the parts not built yet: the design lives at tag `parked/blender-node-graphs-design` (branch `libby/blender-node-graphs-design`, PR #3, closed unmerged), not in this tree.
|
||||||
|
|
||||||
|
| action | fields | rule |
|
||||||
|
|---|---|---|
|
||||||
|
| `createNodeGroup` | `treeKey`, `interface` `[{inOut, name, type, default?}]` | New geometry node group with `gin` and `gout` nodes. Types: Geometry, Float, Int, Bool, Vector. Refuses an existing group. The receipt lists the interface identifiers (`Socket_N`). |
|
||||||
|
| `addNode` | `treeKey`, `id`, `blIdname`, `location?`, `label?`, `properties?`, `inputs?` | `id` is Brokkr's key (stored as the node's `brokkr_id`). Type must be a `GeometryNode*`, `FunctionNode*` or `ShaderNode*` name that is not an importer, exporter, script, bake, group, zone, viewer or image-texture node. Properties are enum/bool/int/float node settings (set first, they decide which sockets exist); inputs are unlinked defaults (float, int, bool, vector, colour). At most 500 nodes per group. |
|
||||||
|
| `removeNode` | `treeKey`, `id` | Removes the node and its links. Absent id: accepted. `gin` and `gout` stay. |
|
||||||
|
| `linkSockets` | `treeKey`, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Sockets by identifier, or by display name when exactly one available socket has it (Group Input/Output sockets are addressed by interface name). A link Blender calls invalid (field into a single value, type mismatch) is removed and refused, and the single link it displaced is put back. A valid link into a single-link input replaces the old one (Blender's behaviour); a link that would close a cycle, or link a node to itself, is refused before Blender is asked. |
|
||||||
|
| `unlinkSockets` | `treeKey`, `toNode`, `toSocket`, `fromNode?`, `fromSocket?` | Removes the links into an input, or only the one from the given source. None to remove: accepted. |
|
||||||
|
| `addModifier` | `objectName`, `treeKey`, `modifierName?` (default `Brokkr GeoNodes`) | `NODES` only. Reuses a modifier of that name; at most 8 per object. |
|
||||||
|
| `removeModifier` | `objectName`, `modifierName?` | Geometry Nodes modifiers only (a Subdivision or other modifier is refused). Absent: accepted. |
|
||||||
|
| `setModifierInput` | `objectName`, `modifierName?`, `socket` (identifier or unique interface name), `value` | Float, Int, Bool, Vector inputs. Written as `getattr(modifier.properties.inputs, id).value = v` (indexing `inputs[id]` yields a raw ID property group without `.value` on 5.2.3), then `object.update_tag()`. |
|
||||||
|
|
||||||
|
`setObjectCustomProperty` refuses a property whose name is the identifier (`Socket_N`) of an input of a Geometry
|
||||||
|
Nodes modifier on that object; a custom property never drives a modifier input. A name that only equals an
|
||||||
|
input's display name is an ordinary custom property.
|
||||||
|
|
||||||
|
The host snapshot's object entries carry `evaluated` for mesh objects with modifiers: vertex, edge and
|
||||||
|
polygon counts, `instanceCount`, and up to 256 vertex and instance locations (`truncated` when cut). Vertex
|
||||||
|
positions and `vertexBounds*` are in the object's local space; instance locations and `instanceBounds*` are
|
||||||
|
world space. Above 1,000,000 vertices only counts are read (`positionsSkipped`); a non-finite coordinate sets
|
||||||
|
`nonFinite` and drops the positions and bounds. The instances come from one depsgraph scan per capture.
|
||||||
|
`instancesComplete` is false when the depsgraph scan stopped at 100,000 items (`instanceCount` and the instance bounds are then lower bounds); the scan is what costs time (about 5 microseconds per instance in Python), so a 2000 x 2000 grid instanced on points captures in 1.4 s on the test machine where an unbounded scan took 22 s. `instanceCount` is `null` for an object the stopped scan never reached (unknown, not zero); the 100,000 budget is shared across all objects in depsgraph order, so a host after a heavy one can be starved. Otherwise `instanceCount` counts evaluated instances whose parent is the object, so a curve, point or volume result
|
||||||
|
also reads as one instance. `mesh` stays the base mesh.
|
||||||
|
|
||||||
|
Known edges: a node duplicated by hand copies its `brokkr_id`, so id lookups act on the first match; `gin`/`gout` are protected by id, and a group whose output node was deleted by hand cannot get a new one through `addNode`; removing a group's last modifier does not delete the group (it may stay in the file until it is saved and reloaded; `addModifier` can reuse it); `removeModifier` takes any modifier name Blender allows because it only looks the modifier up.
|
||||||
|
|
||||||
|
Not built: zones, item sockets, multi-input ordering, library groups, group-in-group nodes, batches/rollback across
|
||||||
|
intents, an expectedRevision check, per-node cost estimates. Int values are capped at +-4096 and floats at +-1e9,
|
||||||
|
which bounds a request but not the cost of a graph: Blender evaluates what it is given, and the snapshot evaluates
|
||||||
|
every mesh with modifiers on each capture (mesh grids measured: 16.7M vertices evaluate and read in 0.4 s).
|
||||||
|
`addModifier` refuses object types that take no modifiers.
|
||||||
|
|
||||||
|
Live proof: `tests/live/gn_actions.py` (Blender 5.2.3, headless). Unit tests: `tests/test_node_actions.py`.
|
||||||
269
surfaces/blender/tests/live/gn_actions.py
Normal file
269
surfaces/blender/tests/live/gn_actions.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""Live proof of the node-graph actions: rebuild three geometry-node setups through Brokkr actions only.
|
||||||
|
|
||||||
|
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
|
||||||
|
blender -b --factory-startup --python gn_actions.py -- result.json
|
||||||
|
|
||||||
|
Each native tree is built with bpy as the reference and evaluated with the depsgraph. The Brokkr side
|
||||||
|
uses BrokkrBlenderTarget.execute_command for every build step and capture_snapshot().objects[].evaluated
|
||||||
|
for every read: no bpy call touches the Brokkr-built scene except the harness's own reference trees.
|
||||||
|
Exit status is non-zero if any check fails.
|
||||||
|
"""
|
||||||
|
import bpy, sys, os, json, types, math
|
||||||
|
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 "gn_actions_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)
|
||||||
|
|
||||||
|
def fresh():
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
return bt.BrokkrBlenderTarget(bpy)
|
||||||
|
|
||||||
|
N = [0]
|
||||||
|
def act(t, **cmd):
|
||||||
|
N[0] += 1; cmd.setdefault("commandId", "c%d" % N[0])
|
||||||
|
r = t.execute_command(bpy.context, cmd)
|
||||||
|
if r["status"] != "accepted": print("REFUSED", cmd["action"], r["message"], flush=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
def ok(t, **cmd):
|
||||||
|
r = act(t, **cmd); check("%s %s" % (cmd["action"], cmd.get("id") or cmd.get("treeKey") or cmd.get("objectName") or ""), r["status"] == "accepted", r["message"]); return r
|
||||||
|
|
||||||
|
def evaluated(t, name):
|
||||||
|
snap = t.capture_snapshot(bpy.context)
|
||||||
|
return [o for o in snap["objects"] if o["name"] == name][0]["evaluated"]
|
||||||
|
|
||||||
|
def native_tree(build):
|
||||||
|
g = bpy.data.node_groups.new("native", "GeometryNodeTree")
|
||||||
|
g.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||||
|
gout = g.nodes.new("NodeGroupOutput"); build(g, gout)
|
||||||
|
me = bpy.data.meshes.new("nm"); ob = bpy.data.objects.new("native_host", me); bpy.context.scene.collection.objects.link(ob)
|
||||||
|
ob.modifiers.new("GN", "NODES").node_group = g
|
||||||
|
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
|
||||||
|
ev = ob.evaluated_get(dg); m = ev.to_mesh()
|
||||||
|
verts = sorted(tuple(round(c, 5) for c in v.co) for v in m.vertices); ev.to_mesh_clear()
|
||||||
|
inst = sorted(tuple(round(c, 5) for c in i.matrix_world.translation) for i in dg.object_instances if i.is_instance)
|
||||||
|
bpy.data.objects.remove(ob); bpy.data.node_groups.remove(g); bpy.data.meshes.remove(me)
|
||||||
|
return verts, inst
|
||||||
|
|
||||||
|
def srt(rows): return sorted(tuple(r) for r in rows)
|
||||||
|
|
||||||
|
def host(t, name, tree, mod="GN"):
|
||||||
|
ok(t, action="createObject", objectType="MESH", name=name)
|
||||||
|
ok(t, action="addModifier", objectName=name, modifierName=mod, treeKey=tree)
|
||||||
|
|
||||||
|
def graph(t, tree, interface, nodes, links):
|
||||||
|
ok(t, action="createNodeGroup", treeKey=tree, interface=interface)
|
||||||
|
for n in nodes: ok(t, action="addNode", treeKey=tree, **n)
|
||||||
|
for a, b, c, d in links: ok(t, action="linkSockets", treeKey=tree, fromNode=a, fromSocket=b, toNode=c, toSocket=d)
|
||||||
|
|
||||||
|
GEO_OUT = [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}]
|
||||||
|
|
||||||
|
# ---- 1. ring of instances
|
||||||
|
def ring(g, gout):
|
||||||
|
c = g.nodes.new("GeometryNodeMeshCircle"); c.inputs["Vertices"].default_value = 8; c.inputs["Radius"].default_value = 2.0
|
||||||
|
cu = g.nodes.new("GeometryNodeMeshCube"); cu.inputs["Size"].default_value = (0.3, 0.3, 0.3)
|
||||||
|
i = g.nodes.new("GeometryNodeInstanceOnPoints")
|
||||||
|
g.links.new(c.outputs["Mesh"], i.inputs["Points"]); g.links.new(cu.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
|
||||||
|
nv, ni = native_tree(ring)
|
||||||
|
t = fresh(); N[0] = 0
|
||||||
|
graph(t, "Ring", GEO_OUT,
|
||||||
|
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 8, "Radius": 2.0}),
|
||||||
|
dict(id="cube", blIdname="GeometryNodeMeshCube", inputs={"Size": [0.3, 0.3, 0.3]}),
|
||||||
|
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
|
||||||
|
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
|
||||||
|
host(t, "RingHost", "Ring")
|
||||||
|
ev = evaluated(t, "RingHost")
|
||||||
|
check("ring instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == 8, {"brokkr": ev["instanceLocations"], "native": ni})
|
||||||
|
# parameter change by setModifierInput on an exposed input: rebuild with an exposed Count
|
||||||
|
t = fresh()
|
||||||
|
graph(t, "RingN", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}],
|
||||||
|
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 2.0}),
|
||||||
|
dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
|
||||||
|
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
|
||||||
|
host(t, "RingHost", "RingN")
|
||||||
|
before = evaluated(t, "RingHost")["instanceCount"]
|
||||||
|
ok(t, action="setModifierInput", objectName="RingHost", modifierName="GN", socket="Count", value=12)
|
||||||
|
after = evaluated(t, "RingHost")["instanceCount"]
|
||||||
|
check("setModifierInput changes evaluated instances", (before, after) == (8, 12), (before, after))
|
||||||
|
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="Socket_1", value=3)
|
||||||
|
check("custom property naming a modifier input identifier is refused", r["status"] == "failed" and "setModifierInput" in r["message"], r["message"])
|
||||||
|
check("refused custom property changed nothing", evaluated(t, "RingHost")["instanceCount"] == 12)
|
||||||
|
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="Count", value=3)
|
||||||
|
check("a custom property sharing only a display name is ordinary", r["status"] == "accepted", r["message"])
|
||||||
|
|
||||||
|
# ---- 2. boundary step
|
||||||
|
B = 0.5
|
||||||
|
def boundary(g, gout):
|
||||||
|
ln = g.nodes.new("GeometryNodeMeshLine"); ln.inputs["Count"].default_value = 3; ln.inputs["Offset"].default_value = (2, 0, 0)
|
||||||
|
pos = g.nodes.new("GeometryNodeInputPosition")
|
||||||
|
d = g.nodes.new("ShaderNodeVectorMath"); d.operation = "LENGTH"
|
||||||
|
c = g.nodes.new("FunctionNodeCompare"); c.data_type = "FLOAT"; c.operation = "GREATER_THAN"; c.inputs["B"].default_value = B
|
||||||
|
nm = g.nodes.new("ShaderNodeVectorMath"); nm.operation = "NORMALIZE"
|
||||||
|
sc = g.nodes.new("ShaderNodeVectorMath"); sc.operation = "SCALE"; sc.inputs["Scale"].default_value = B
|
||||||
|
sp = g.nodes.new("GeometryNodeSetPosition")
|
||||||
|
g.links.new(pos.outputs[0], d.inputs[0]); g.links.new(d.outputs["Value"], c.inputs["A"]); g.links.new(pos.outputs[0], nm.inputs[0]); g.links.new(nm.outputs[0], sc.inputs[0])
|
||||||
|
g.links.new(ln.outputs["Mesh"], sp.inputs["Geometry"]); g.links.new(c.outputs["Result"], sp.inputs["Selection"]); g.links.new(sc.outputs[0], sp.inputs["Position"]); g.links.new(sp.outputs["Geometry"], gout.inputs["Geometry"])
|
||||||
|
nv, ni = native_tree(boundary)
|
||||||
|
t = fresh()
|
||||||
|
graph(t, "Boundary", GEO_OUT,
|
||||||
|
[dict(id="line", blIdname="GeometryNodeMeshLine", inputs={"Count": 3, "Offset": [2, 0, 0]}),
|
||||||
|
dict(id="pos", blIdname="GeometryNodeInputPosition"),
|
||||||
|
dict(id="len", blIdname="ShaderNodeVectorMath", properties={"operation": "LENGTH"}),
|
||||||
|
dict(id="gt", blIdname="FunctionNodeCompare", properties={"data_type": "FLOAT", "operation": "GREATER_THAN"}, inputs={"B": B}),
|
||||||
|
dict(id="nrm", blIdname="ShaderNodeVectorMath", properties={"operation": "NORMALIZE"}),
|
||||||
|
dict(id="edge", blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": B}),
|
||||||
|
dict(id="set", blIdname="GeometryNodeSetPosition")],
|
||||||
|
[("pos", "Position", "len", "Vector"), ("len", "Value", "gt", "A"), ("pos", "Position", "nrm", "Vector"), ("nrm", "Vector", "edge", "Vector"),
|
||||||
|
("line", "Mesh", "set", "Geometry"), ("gt", "Result", "set", "Selection"), ("edge", "Vector", "set", "Position"), ("set", "Geometry", "gout", "Geometry")])
|
||||||
|
host(t, "BoundaryHost", "Boundary")
|
||||||
|
ev = evaluated(t, "BoundaryHost")
|
||||||
|
check("boundary_step vertices equal native", srt(ev["vertexLocations"]) == nv, {"brokkr": ev["vertexLocations"], "native": nv})
|
||||||
|
|
||||||
|
# ---- 3. seeded scatter
|
||||||
|
def scatter(g, gout):
|
||||||
|
gr = g.nodes.new("GeometryNodeMeshGrid"); gr.inputs["Size X"].default_value = 3; gr.inputs["Size Y"].default_value = 3
|
||||||
|
di = g.nodes.new("GeometryNodeDistributePointsOnFaces"); di.distribute_method = "RANDOM"; di.inputs["Density"].default_value = 4; di.inputs["Seed"].default_value = 7
|
||||||
|
co = g.nodes.new("GeometryNodeMeshCone"); co.inputs["Radius Bottom"].default_value = 0.05; co.inputs["Depth"].default_value = 0.4
|
||||||
|
i = g.nodes.new("GeometryNodeInstanceOnPoints")
|
||||||
|
g.links.new(gr.outputs["Mesh"], di.inputs["Mesh"]); g.links.new(di.outputs["Points"], i.inputs["Points"]); g.links.new(co.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
|
||||||
|
nv, ni = native_tree(scatter)
|
||||||
|
t = fresh()
|
||||||
|
graph(t, "Scatter", GEO_OUT + [{"inOut": "INPUT", "name": "Seed", "type": "Int", "default": 7}],
|
||||||
|
[dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Size X": 3, "Size Y": 3}),
|
||||||
|
dict(id="dist", blIdname="GeometryNodeDistributePointsOnFaces", properties={"distribute_method": "RANDOM"}, inputs={"Density": 4, "Seed": 7}),
|
||||||
|
dict(id="cone", blIdname="GeometryNodeMeshCone", inputs={"Radius Bottom": 0.05, "Depth": 0.4}),
|
||||||
|
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
|
||||||
|
[("grid", "Mesh", "dist", "Mesh"), ("dist", "Points", "iop", "Points"), ("cone", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
|
||||||
|
host(t, "ScatterHost", "Scatter")
|
||||||
|
ev = evaluated(t, "ScatterHost")
|
||||||
|
check("scatter instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == len(ni), {"brokkr": ev["instanceCount"], "native": len(ni)})
|
||||||
|
ok(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value=7) # exposed but unwired: no effect
|
||||||
|
check("unwired exposed input leaves result", srt(evaluated(t, "ScatterHost")["instanceLocations"]) == ni)
|
||||||
|
|
||||||
|
# ---- refusals on the real API
|
||||||
|
r = act(t, action="addNode", treeKey="Scatter", id="bad", blIdname="GeometryNodeMeshCube", inputs={"Nope": 1}); check("unknown input refused", r["status"] == "failed", r["message"])
|
||||||
|
check("refused addNode left no node", not any(n.get("brokkr_id") == "bad" for n in bpy.data.node_groups["Scatter"].nodes))
|
||||||
|
r = act(t, action="addNode", treeKey="Scatter", id="bad2", blIdname="GeometryNodeMeshCube", properties={"nope": 1}); check("unknown property refused", r["status"] == "failed", r["message"])
|
||||||
|
r = act(t, action="addNode", treeKey="Scatter", id="bad3", blIdname="GeometryNodeImportOBJ"); check("file-reading node refused", r["status"] == "failed", r["message"])
|
||||||
|
r = act(t, action="addNode", treeKey="Scatter", id="bad4", blIdname="GeometryNodeNoSuchThing"); check("unknown node type refused", r["status"] == "failed", r["message"])
|
||||||
|
r = act(t, action="linkSockets", treeKey="Scatter", fromNode="cone", fromSocket="Mesh", toNode="dist", toSocket="Density"); check("invalid link refused and removed", r["status"] == "failed" and not any(l.to_socket.name == "Density" for l in bpy.data.node_groups["Scatter"].links), r["message"])
|
||||||
|
r = act(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value="x"); check("wrong value type refused", r["status"] == "failed", r["message"])
|
||||||
|
r = act(t, action="addNode", treeKey="Scatter", id="grid", blIdname="GeometryNodeMeshCube"); check("duplicate id refused", r["status"] == "failed", r["message"])
|
||||||
|
|
||||||
|
t2 = fresh()
|
||||||
|
graph(t2, "L", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
|
||||||
|
[("cube", "Mesh", "iop", "Points")])
|
||||||
|
before = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
|
||||||
|
r = act(t2, action="linkSockets", treeKey="L", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
|
||||||
|
after = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
|
||||||
|
check("refused link keeps the link it would have replaced", r["status"] == "failed" and before == after == [("cube", "Points")], (r["message"], before, after))
|
||||||
|
ok(t2, action="createObject", objectType="CAMERA", name="Cam")
|
||||||
|
r = act(t2, action="addModifier", objectName="Cam", treeKey="L"); check("modifier on a camera refused clearly", r["status"] == "failed" and "CAMERA" in r["message"], r["message"])
|
||||||
|
r = act(t2, action="addNode", treeKey="L", id="n\n", blIdname="ShaderNodeMath"); check("trailing newline in an id refused", r["status"] == "failed", r["message"])
|
||||||
|
# off-origin host: vertices are local, instances world, bounds kept apart
|
||||||
|
t3 = fresh()
|
||||||
|
graph(t3, "Off", GEO_OUT, [dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 4, "Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"), dict(id="join", blIdname="GeometryNodeJoinGeometry")],
|
||||||
|
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "join", "Geometry"), ("circle", "Mesh", "join", "Geometry"), ("join", "Geometry", "gout", "Geometry")])
|
||||||
|
ok(t3, action="createObject", objectType="MESH", name="OffHost", location=[100, 0, 0]); ok(t3, action="addModifier", objectName="OffHost", modifierName="GN", treeKey="Off")
|
||||||
|
ev = evaluated(t3, "OffHost")
|
||||||
|
check("local vertex bounds and world instance bounds are separate", max(ev["vertexBoundsMax"]) <= 1.01 and ev["instanceBoundsMin"][0] >= 98.9 and ev["instanceCount"] == 4, ev)
|
||||||
|
# non-finite positions are flagged
|
||||||
|
ok(t3, action="createNodeGroup", treeKey="Inf", interface=GEO_OUT)
|
||||||
|
for i in range(6): ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9, "Vector": [1, 1, 1]}) if i == 0 else ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9})
|
||||||
|
ok(t3, action="addNode", treeKey="Inf", id="cube", blIdname="GeometryNodeMeshCube"); ok(t3, action="addNode", treeKey="Inf", id="set", blIdname="GeometryNodeSetPosition")
|
||||||
|
for i in range(1, 6): ok(t3, action="linkSockets", treeKey="Inf", fromNode="s%d" % (i - 1), fromSocket="Vector", toNode="s%d" % i, toSocket="Vector")
|
||||||
|
ok(t3, action="linkSockets", treeKey="Inf", fromNode="cube", fromSocket="Mesh", toNode="set", toSocket="Geometry"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="s5", fromSocket="Vector", toNode="set", toSocket="Position"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="set", fromSocket="Geometry", toNode="gout", toSocket="Geometry")
|
||||||
|
ok(t3, action="createObject", objectType="MESH", name="InfHost"); ok(t3, action="addModifier", objectName="InfHost", treeKey="Inf")
|
||||||
|
ev = evaluated(t3, "InfHost")
|
||||||
|
check("non-finite evaluated positions are flagged and not published", ev["nonFinite"] and ev["vertexLocations"] == [] and json.dumps(ev, allow_nan=False), ev)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- removal, unlinking, cycles (cut 2)
|
||||||
|
import time
|
||||||
|
t4 = fresh()
|
||||||
|
graph(t4, "R", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 4}],
|
||||||
|
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"),
|
||||||
|
dict(id="m1", blIdname="ShaderNodeMath"), dict(id="m2", blIdname="ShaderNodeMath")],
|
||||||
|
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry"), ("m1", "Value", "m2", "Value")])
|
||||||
|
host(t4, "RHost", "R")
|
||||||
|
check("graph evaluates before removal", evaluated(t4, "RHost")["instanceCount"] == 4)
|
||||||
|
for src, dst in (("m2", "m1"), ("m1", "m1")):
|
||||||
|
r = act(t4, action="linkSockets", treeKey="R", fromNode=src, fromSocket="Value", toNode=dst, toSocket="Value_001"); check("cycle %s->%s refused" % (src, dst), r["status"] == "failed" and "cycle" in r["message"], r["message"])
|
||||||
|
check("refused cycle left the link set alone", len(bpy.data.node_groups["R"].links) == 5)
|
||||||
|
r = ok(t4, action="unlinkSockets", treeKey="R", fromNode="m1", fromSocket="Value", toNode="m2", toSocket="Value")
|
||||||
|
check("unlinked exactly one link", len(bpy.data.node_groups["R"].links) == 4, r["message"])
|
||||||
|
r = ok(t4, action="unlinkSockets", treeKey="R", toNode="m2", toSocket="Value"); check("unlink of nothing is accepted", "Removed 0" in r["message"], r["message"])
|
||||||
|
ok(t4, action="unlinkSockets", treeKey="R", toNode="iop", toSocket="Instance")
|
||||||
|
check("unlinking the instance changes the result", evaluated(t4, "RHost")["instanceCount"] == 0, evaluated(t4, "RHost"))
|
||||||
|
ok(t4, action="linkSockets", treeKey="R", fromNode="cube", fromSocket="Mesh", toNode="iop", toSocket="Instance")
|
||||||
|
check("relinked", evaluated(t4, "RHost")["instanceCount"] == 4)
|
||||||
|
links_before = len(bpy.data.node_groups["R"].links)
|
||||||
|
ok(t4, action="removeNode", treeKey="R", id="cube")
|
||||||
|
check("removeNode drops the node and its links", not any(n.get("brokkr_id") == "cube" for n in bpy.data.node_groups["R"].nodes) and len(bpy.data.node_groups["R"].links) == links_before - 1)
|
||||||
|
r = act(t4, action="removeNode", treeKey="R", id="gout"); check("group output node stays", r["status"] == "failed", r["message"])
|
||||||
|
check("removeNode of an absent id is accepted", act(t4, action="removeNode", treeKey="R", id="cube")["status"] == "accepted")
|
||||||
|
ok(t4, action="removeModifier", objectName="RHost", modifierName="GN")
|
||||||
|
check("removeModifier removes it; evaluated block gone", len(bpy.data.objects["RHost"].modifiers) == 0 and evaluated(t4, "RHost") is None)
|
||||||
|
bpy.data.objects["RHost"].modifiers.new("Sub", "SUBSURF")
|
||||||
|
r = act(t4, action="removeModifier", objectName="RHost", modifierName="Sub"); check("non-Geometry-Nodes modifier is not removed", r["status"] == "failed" and len(bpy.data.objects["RHost"].modifiers) == 1, r["message"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cycle detection on a long chain (bpy hands out a fresh wrapper per access; id() would be reused)
|
||||||
|
t5 = fresh()
|
||||||
|
ok(t5, action="createNodeGroup", treeKey="C", interface=GEO_OUT)
|
||||||
|
ids = ["c%d" % i for i in range(60)]
|
||||||
|
for i in ids: act(t5, action="addNode", treeKey="C", id=i, blIdname="ShaderNodeMath")
|
||||||
|
for a_, b_ in zip(ids, ids[1:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value")
|
||||||
|
for a_, b_ in zip(ids[:-2], ids[2:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value_001")
|
||||||
|
before = len(bpy.data.node_groups["C"].links)
|
||||||
|
t0 = time.time()
|
||||||
|
refused = [act(t5, action="linkSockets", treeKey="C", fromNode=ids[-1], fromSocket="Value", toNode=h, toSocket="Value_002")["status"] == "failed" for h in ids[:-1]]
|
||||||
|
R["cycle_probe_seconds"] = round(time.time() - t0, 3)
|
||||||
|
check("every closing link on a 60-node chain with skip links is refused", all(refused) and len(bpy.data.node_groups["C"].links) == before, (sum(refused), len(refused), R["cycle_probe_seconds"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- fix-pass checks: muted state survives a refused link; hand-made Unicode modifier names are removable
|
||||||
|
t6 = fresh()
|
||||||
|
graph(t6, "Mu", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")], [("cube", "Mesh", "iop", "Points")])
|
||||||
|
for l in bpy.data.node_groups["Mu"].links:
|
||||||
|
if l.to_node.get("brokkr_id") == "iop": l.is_muted = True
|
||||||
|
r = act(t6, action="linkSockets", treeKey="Mu", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
|
||||||
|
muted = [l.is_muted for l in bpy.data.node_groups["Mu"].links if l.to_node.get("brokkr_id") == "iop"]
|
||||||
|
check("refused link restores the displaced link's muted state", r["status"] == "failed" and muted == [True], (r["message"], muted))
|
||||||
|
ok(t6, action="createObject", objectType="MESH", name="U")
|
||||||
|
bpy.data.objects["U"].modifiers.new("G\u00e9 x", "NODES")
|
||||||
|
r = act(t6, action="removeModifier", objectName="U", modifierName="G\u00e9 x")
|
||||||
|
check("hand-made Unicode modifier name is removable", r["status"] == "accepted" and len(bpy.data.objects["U"].modifiers) == 0, r["message"])
|
||||||
|
|
||||||
|
# ---- cost: a heavy graph must not stall capture
|
||||||
|
def heavy(n, instanced):
|
||||||
|
tt = fresh()
|
||||||
|
nodes = [dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Vertices X": n, "Vertices Y": n})]
|
||||||
|
links = []
|
||||||
|
if instanced:
|
||||||
|
nodes += [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")]
|
||||||
|
links = [("grid", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")]
|
||||||
|
else:
|
||||||
|
links = [("grid", "Mesh", "gout", "Geometry")]
|
||||||
|
graph(tt, "Heavy", GEO_OUT, nodes, links); host(tt, "HeavyHost", "Heavy")
|
||||||
|
t0 = time.time(); snap = tt.capture_snapshot(bpy.context); dt = time.time() - t0
|
||||||
|
return dt, [o for o in snap["objects"] if o["name"] == "HeavyHost"][0]["evaluated"]
|
||||||
|
dt, ev = heavy(2000, True)
|
||||||
|
R["capture_2000_grid_instanced_seconds"] = round(dt, 3)
|
||||||
|
check("2000x2000 instanced grid: capture is bounded", dt < 3.0 and ev["instancesComplete"] is False and ev["instanceCount"] <= 100000, (round(dt, 2), ev["instanceCount"], ev["instancesComplete"]))
|
||||||
|
dt, ev = heavy(2000, False)
|
||||||
|
R["capture_2000_grid_mesh_seconds"] = round(dt, 3)
|
||||||
|
check("2000x2000 mesh grid: counts only, bounded", dt < 3.0 and ev["vertexCount"] == 4000000 and ev["positionsSkipped"], (round(dt, 2), ev["vertexCount"]))
|
||||||
|
|
||||||
|
json.dump(R, open(out, "w"), indent=1, default=str)
|
||||||
|
bad = [c for c in R["checks"] if not c["ok"]]
|
||||||
|
print("DONE", len(R["checks"]), "checks,", len(bad), "failed")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
1097
surfaces/blender/tests/live/gn_actions.result.json
Normal file
1097
surfaces/blender/tests/live/gn_actions.result.json
Normal file
File diff suppressed because it is too large
Load diff
178
surfaces/blender/tests/live/gn_recreation.py
Normal file
178
surfaces/blender/tests/live/gn_recreation.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Recreate geometry-node setups through the Brokkr scene/object actions and compare with the native result.
|
||||||
|
|
||||||
|
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
|
||||||
|
blender -b --factory-startup --python gn_recreation.py -- result.json
|
||||||
|
|
||||||
|
Each setup is built twice: natively (geometry-nodes tree via bpy, evaluated) and through
|
||||||
|
BrokkrBlenderTarget.execute_command using only the actions that exist. Prints GAP lines where an
|
||||||
|
action that would be needed is refused or has no effect. Not part of the unittest run (needs Blender).
|
||||||
|
"""
|
||||||
|
import bpy, sys, os, json, types, math, random
|
||||||
|
from pathlib import Path
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
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 "gn_result.json"
|
||||||
|
R = {"blender": bpy.app.version_string, "setups": {}, "gaps": {}}
|
||||||
|
|
||||||
|
def reset():
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
return bt.BrokkrBlenderTarget(bpy)
|
||||||
|
|
||||||
|
def act(t, **cmd):
|
||||||
|
cmd.setdefault("commandId", "c%d" % act.n); act.n += 1
|
||||||
|
return t.execute_command(bpy.context, cmd)
|
||||||
|
act.n = 0
|
||||||
|
|
||||||
|
def gap(key, **detail):
|
||||||
|
R["gaps"][key] = detail; print("GAP", key, json.dumps(detail), flush=True)
|
||||||
|
|
||||||
|
# --- native helpers ---------------------------------------------------------------------
|
||||||
|
def tree(name, extra_out=True):
|
||||||
|
t = bpy.data.node_groups.new(name, "GeometryNodeTree")
|
||||||
|
t.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||||
|
gout = t.nodes.new("NodeGroupOutput")
|
||||||
|
return t, gout
|
||||||
|
|
||||||
|
def host(name, gtree, mesh=None):
|
||||||
|
me = mesh or bpy.data.meshes.new(name)
|
||||||
|
ob = bpy.data.objects.new(name, me); bpy.context.scene.collection.objects.link(ob)
|
||||||
|
m = ob.modifiers.new("GN", "NODES"); m.node_group = gtree
|
||||||
|
return ob
|
||||||
|
|
||||||
|
def evaluated_instances(ob):
|
||||||
|
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
|
||||||
|
return [tuple(round(c, 5) for c in inst.matrix_world.translation) for inst in dg.object_instances if inst.is_instance]
|
||||||
|
|
||||||
|
def evaluated_verts(ob):
|
||||||
|
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
|
||||||
|
me = ob.evaluated_get(dg).to_mesh()
|
||||||
|
v = [tuple(round(c, 5) for c in p.co) for p in me.vertices]; ob.evaluated_get(dg).to_mesh_clear(); return v
|
||||||
|
|
||||||
|
def brokkr_positions(prefix):
|
||||||
|
bpy.context.view_layer.update() # matrix_world of the last-edited object is stale until then
|
||||||
|
return sorted(tuple(round(c, 5) for c in o.matrix_world.translation) for o in bpy.data.objects if o.name.startswith(prefix))
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# 1. Ring of instances (essentials-style array: Mesh Circle -> Instance on Points)
|
||||||
|
# =========================================================================================
|
||||||
|
t = reset(); N, RAD = 8, 2.0
|
||||||
|
g, gout = tree("Ring")
|
||||||
|
circ = g.nodes.new("GeometryNodeMeshCircle"); circ.inputs["Vertices"].default_value = N; circ.inputs["Radius"].default_value = RAD
|
||||||
|
cube = g.nodes.new("GeometryNodeMeshCube"); cube.inputs["Size"].default_value = (0.3, 0.3, 0.3)
|
||||||
|
iop = g.nodes.new("GeometryNodeInstanceOnPoints")
|
||||||
|
g.links.new(circ.outputs["Mesh"], iop.inputs["Points"]); g.links.new(cube.outputs["Mesh"], iop.inputs["Instance"])
|
||||||
|
g.links.new(iop.outputs["Instances"], gout.inputs["Geometry"])
|
||||||
|
ob = host("RingHost", g)
|
||||||
|
native = sorted(evaluated_instances(ob))
|
||||||
|
bpy.data.objects.remove(ob)
|
||||||
|
acts = 0; fails = 0
|
||||||
|
for i in range(N):
|
||||||
|
a = 2 * math.pi * i / N
|
||||||
|
r1 = act(t, action="createObject", objectType="MESH", name="Ring.%d" % i, size=0.3); acts += 1
|
||||||
|
r2 = act(t, action="setObjectTransform", targetObjectName="Ring.%d" % i, location=[RAD * math.cos(a), RAD * math.sin(a), 0]); acts += 1
|
||||||
|
fails += (r1["status"] != "accepted") + (r2["status"] != "accepted")
|
||||||
|
bro = brokkr_positions("Ring.")
|
||||||
|
ok = native == bro
|
||||||
|
R["setups"]["ring"] = {"native_instances": len(native), "brokkr_objects": len(bro), "positions_equal": ok, "actions": acts, "failed_actions": fails}
|
||||||
|
print("SETUP ring", R["setups"]["ring"]); print("RING native", native); print("RING brokkr", bro)
|
||||||
|
# size parity
|
||||||
|
cube_dim = bpy.data.objects["Ring.0"].dimensions[:]
|
||||||
|
R["setups"]["ring"]["brokkr_object_dimensions"] = [round(d, 4) for d in cube_dim]
|
||||||
|
# live-ness: the native ring re-evaluates when Count changes; the Brokkr one needs N more actions.
|
||||||
|
# GAP: any node-graph action
|
||||||
|
for aname, extra in (("createNodeGroup", {}), ("addModifier", {"modifierType": "NODES"}), ("addNode", {}), ("setModifierInput", {"socket": "Socket_2", "value": 12})):
|
||||||
|
r = act(t, action=aname, targetObjectName="Ring.0", **extra)
|
||||||
|
R["gaps"].setdefault("no_node_actions", {})[aname] = [r["status"], r["message"]]
|
||||||
|
print("GAP no_node_actions", R["gaps"]["no_node_actions"])
|
||||||
|
# GAP: instances need one object each; no instancing/collection-instance action; mesh primitive is cube only
|
||||||
|
gap("no_primitive_choice", createObject_MESH="always primitive_cube_add; 'size' only",
|
||||||
|
repro="createObject objectType=MESH name=C -> verts=%d" % len(bpy.data.objects["Ring.0"].data.vertices))
|
||||||
|
# GAP: setObjectCustomProperty cannot drive a modifier input
|
||||||
|
g2, go2 = tree("Param")
|
||||||
|
circ2 = g2.nodes.new("GeometryNodeMeshCircle")
|
||||||
|
g2.interface.new_socket("N", in_out="INPUT", socket_type="NodeSocketInt")
|
||||||
|
gi = g2.nodes.new("NodeGroupInput"); g2.links.new(gi.outputs["N"], circ2.inputs["Vertices"])
|
||||||
|
g2.links.new(circ2.outputs["Mesh"], go2.inputs["Geometry"])
|
||||||
|
hob = host("ParamHost", g2)
|
||||||
|
ident = [s.identifier for s in g2.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"][0]
|
||||||
|
getattr(hob.modifiers["GN"].properties.inputs, ident).__setattr__("value", 5) # inputs[ident] is a raw IDPropertyGroup with no .value
|
||||||
|
before = len(evaluated_verts(hob))
|
||||||
|
r = act(t, action="setObjectCustomProperty", targetObjectName="ParamHost", propertyName=ident, value=12)
|
||||||
|
after = len(evaluated_verts(hob))
|
||||||
|
snap = t.capture_snapshot(bpy.context)
|
||||||
|
ho = [o for o in snap["objects"] if o["name"] == "ParamHost"][0]
|
||||||
|
gap("custom_property_does_not_drive_modifier", receipt=r["status"], verts_before=before, verts_after=after, identifier=ident,
|
||||||
|
snapshot_modifier=ho["modifiers"], snapshot_mesh=ho["mesh"],
|
||||||
|
repro="setObjectCustomProperty on a GN host object -> accepted, evaluated vertex count unchanged; snapshot reports base mesh counts and no modifier inputs")
|
||||||
|
bpy.data.objects.remove(hob)
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# 2. Boundary step (example 2): pull points outside radius B back to the circle
|
||||||
|
# =========================================================================================
|
||||||
|
t = reset(); B = 0.5; XS = [0.0, 2.0, 4.0]
|
||||||
|
g, gout = tree("Boundary")
|
||||||
|
line = g.nodes.new("GeometryNodeMeshLine"); line.inputs["Count"].default_value = 3
|
||||||
|
line.inputs["Start Location"].default_value = (0, 0, 0); line.inputs["Offset"].default_value = (2, 0, 0)
|
||||||
|
pos = g.nodes.new("GeometryNodeInputPosition")
|
||||||
|
dist = g.nodes.new("ShaderNodeVectorMath"); dist.operation = "LENGTH"
|
||||||
|
cmp_ = g.nodes.new("FunctionNodeCompare"); cmp_.data_type = "FLOAT"; cmp_.operation = "GREATER_THAN"; cmp_.inputs[1].default_value = B
|
||||||
|
nrm = g.nodes.new("ShaderNodeVectorMath"); nrm.operation = "NORMALIZE"
|
||||||
|
sc = g.nodes.new("ShaderNodeVectorMath"); sc.operation = "SCALE"; sc.inputs["Scale"].default_value = B
|
||||||
|
sp = g.nodes.new("GeometryNodeSetPosition")
|
||||||
|
g.links.new(pos.outputs[0], dist.inputs[0]); g.links.new(dist.outputs["Value"], cmp_.inputs[0])
|
||||||
|
g.links.new(pos.outputs[0], nrm.inputs[0]); g.links.new(nrm.outputs[0], sc.inputs[0])
|
||||||
|
g.links.new(line.outputs["Mesh"], sp.inputs["Geometry"]); g.links.new(cmp_.outputs["Result"], sp.inputs["Selection"])
|
||||||
|
g.links.new(sc.outputs[0], sp.inputs["Position"]); g.links.new(sp.outputs["Geometry"], gout.inputs["Geometry"])
|
||||||
|
ob = host("BoundaryHost", g); native = sorted(evaluated_verts(ob)); bpy.data.objects.remove(ob)
|
||||||
|
acts = 0
|
||||||
|
for i, x in enumerate(XS):
|
||||||
|
v = Vector((x, 0, 0)); nv = v if v.length <= B else v.normalized() * B # the *client* does the field math
|
||||||
|
act(t, action="createObject", objectType="EMPTY", name="P.%d" % i); act(t, action="setObjectTransform", targetObjectName="P.%d" % i, location=list(nv)); acts += 2
|
||||||
|
bro = brokkr_positions("P.")
|
||||||
|
R["setups"]["boundary_step"] = {"native": native, "brokkr": bro, "equal": native == bro, "actions": acts,
|
||||||
|
"note": "equality needs the client to evaluate the field; Brokkr only carries the result"}
|
||||||
|
print("SETUP boundary_step", R["setups"]["boundary_step"])
|
||||||
|
gap("no_field_evaluation", repro="no action takes an expression/field; boundary_step's per-element rule must be computed by the caller and sent as N setObjectTransform")
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# 3. Seeded scatter (grass-tuft front half): Distribute Points on Faces -> Instance on Points
|
||||||
|
# =========================================================================================
|
||||||
|
t = reset(); SEED = 7
|
||||||
|
g, gout = tree("Scatter")
|
||||||
|
grid = g.nodes.new("GeometryNodeMeshGrid"); grid.inputs["Size X"].default_value = 3; grid.inputs["Size Y"].default_value = 3
|
||||||
|
dis = g.nodes.new("GeometryNodeDistributePointsOnFaces"); dis.distribute_method = "RANDOM"
|
||||||
|
dis.inputs["Density"].default_value = 4; dis.inputs["Seed"].default_value = SEED
|
||||||
|
cone = g.nodes.new("GeometryNodeMeshCone"); cone.inputs["Radius Bottom"].default_value = 0.05; cone.inputs["Depth"].default_value = 0.4
|
||||||
|
iop = g.nodes.new("GeometryNodeInstanceOnPoints")
|
||||||
|
g.links.new(grid.outputs["Mesh"], dis.inputs["Mesh"]); g.links.new(dis.outputs["Points"], iop.inputs["Points"])
|
||||||
|
g.links.new(cone.outputs["Mesh"], iop.inputs["Instance"]); g.links.new(iop.outputs["Instances"], gout.inputs["Geometry"])
|
||||||
|
ob = host("ScatterHost", g); native = sorted(evaluated_instances(ob))
|
||||||
|
# what Brokkr can see of that object
|
||||||
|
snap = t.capture_snapshot(bpy.context); so = [o for o in snap["objects"] if o["name"] == "ScatterHost"][0]
|
||||||
|
gap("snapshot_not_evaluated", brokkr_sees=so["mesh"], modifiers=so["modifiers"], evaluated_instances=len(native),
|
||||||
|
repro="host object with Distribute->InstanceOnPoints: snapshot mesh.vertexCount=0, no instance count, no positions")
|
||||||
|
# (a) replay the native points through actions (needs a side channel that is not Brokkr)
|
||||||
|
bpy.data.objects.remove(ob); acts = 0
|
||||||
|
for i, p in enumerate(native):
|
||||||
|
act(t, action="createObject", objectType="MESH", name="S.%d" % i, size=0.1); act(t, action="setObjectTransform", targetObjectName="S.%d" % i, location=list(p)); acts += 2
|
||||||
|
R["setups"]["scatter_replay"] = {"native_instances": len(native), "equal": brokkr_positions("S.") == native, "actions": acts,
|
||||||
|
"note": "positions were read with bpy depsgraph, not through any Brokkr surface"}
|
||||||
|
# (b) recreate from the seed with the caller's own RNG: does not match Blender's point hash
|
||||||
|
random.seed(SEED)
|
||||||
|
mine = sorted((round(random.uniform(-1.5, 1.5), 5), round(random.uniform(-1.5, 1.5), 5), 0.0) for _ in native)
|
||||||
|
R["setups"]["scatter_own_rng"] = {"count_equal": len(mine) == len(native), "positions_equal": mine == [(a, b, 0.0) for a, b, _ in native] or mine == native}
|
||||||
|
print("SETUP scatter", R["setups"]["scatter_replay"], R["setups"]["scatter_own_rng"])
|
||||||
|
gap("no_seed_parity", repro="Distribute Points on Faces seed=7 density=4 on a 3x3 grid: positions are Blender's hash; only reading them back (not offered) reproduces them")
|
||||||
|
# --- render check: the Brokkr scene still renders under the existing actions
|
||||||
|
act(t, action="createObject", objectType="CAMERA", name="Cam", location=[0, -6, 4]); act(t, action="setCamera", targetObjectName="Cam", active=True, lens=35)
|
||||||
|
os.makedirs(os.environ.get("BROKKR_RUN", "."), exist_ok=True)
|
||||||
|
t.configure_action_dirs(os.environ.get("BROKKR_RUN", "."), os.environ.get("BROKKR_RUN", "."))
|
||||||
|
r = act(t, action="renderStill", outputPath="scatter_replay.png", engine="BLENDER_WORKBENCH", resolutionX=64, resolutionY=64, cameraName="Cam")
|
||||||
|
R["setups"]["scatter_replay"]["render"] = [r["status"], r["message"]]
|
||||||
|
json.dump(R, open(out, "w"), indent=1, default=str)
|
||||||
|
print("DONE", out)
|
||||||
141
surfaces/blender/tests/live/gn_recreation.result.json
Normal file
141
surfaces/blender/tests/live/gn_recreation.result.json
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"blender": "5.2.3 LTS",
|
||||||
|
"setups": {
|
||||||
|
"ring": {
|
||||||
|
"native_instances": 8,
|
||||||
|
"brokkr_objects": 8,
|
||||||
|
"positions_equal": true,
|
||||||
|
"actions": 16,
|
||||||
|
"failed_actions": 0,
|
||||||
|
"brokkr_object_dimensions": [
|
||||||
|
0.3,
|
||||||
|
0.3,
|
||||||
|
0.3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"boundary_step": {
|
||||||
|
"native": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"brokkr": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.5,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"equal": true,
|
||||||
|
"actions": 6,
|
||||||
|
"note": "equality needs the client to evaluate the field; Brokkr only carries the result"
|
||||||
|
},
|
||||||
|
"scatter_replay": {
|
||||||
|
"native_instances": 38,
|
||||||
|
"equal": true,
|
||||||
|
"actions": 76,
|
||||||
|
"note": "positions were read with bpy depsgraph, not through any Brokkr surface",
|
||||||
|
"render": [
|
||||||
|
"accepted",
|
||||||
|
"Rendered still to scatter_replay.png."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scatter_own_rng": {
|
||||||
|
"count_equal": true,
|
||||||
|
"positions_equal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gaps": {
|
||||||
|
"no_node_actions": {
|
||||||
|
"createNodeGroup": [
|
||||||
|
"failed",
|
||||||
|
"Unsupported Blender command action: createNodeGroup"
|
||||||
|
],
|
||||||
|
"addModifier": [
|
||||||
|
"failed",
|
||||||
|
"Unsupported Blender command action: addModifier"
|
||||||
|
],
|
||||||
|
"addNode": [
|
||||||
|
"failed",
|
||||||
|
"Unsupported Blender command action: addNode"
|
||||||
|
],
|
||||||
|
"setModifierInput": [
|
||||||
|
"failed",
|
||||||
|
"Unsupported Blender command action: setModifierInput"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_primitive_choice": {
|
||||||
|
"createObject_MESH": "always primitive_cube_add; 'size' only",
|
||||||
|
"repro": "createObject objectType=MESH name=C -> verts=8"
|
||||||
|
},
|
||||||
|
"custom_property_does_not_drive_modifier": {
|
||||||
|
"receipt": "accepted",
|
||||||
|
"verts_before": 5,
|
||||||
|
"verts_after": 5,
|
||||||
|
"identifier": "Socket_1",
|
||||||
|
"snapshot_modifier": [
|
||||||
|
{
|
||||||
|
"name": "GN",
|
||||||
|
"type": "NODES",
|
||||||
|
"showViewport": true,
|
||||||
|
"showRender": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snapshot_mesh": {
|
||||||
|
"vertexCount": 0,
|
||||||
|
"edgeCount": 0,
|
||||||
|
"polygonCount": 0,
|
||||||
|
"uvLayerCount": 0,
|
||||||
|
"shapeKeyCount": 0
|
||||||
|
},
|
||||||
|
"repro": "setObjectCustomProperty on a GN host object -> accepted, evaluated vertex count unchanged; snapshot reports base mesh counts and no modifier inputs"
|
||||||
|
},
|
||||||
|
"no_field_evaluation": {
|
||||||
|
"repro": "no action takes an expression/field; boundary_step's per-element rule must be computed by the caller and sent as N setObjectTransform"
|
||||||
|
},
|
||||||
|
"snapshot_not_evaluated": {
|
||||||
|
"brokkr_sees": {
|
||||||
|
"vertexCount": 0,
|
||||||
|
"edgeCount": 0,
|
||||||
|
"polygonCount": 0,
|
||||||
|
"uvLayerCount": 0,
|
||||||
|
"shapeKeyCount": 0
|
||||||
|
},
|
||||||
|
"modifiers": [
|
||||||
|
{
|
||||||
|
"name": "GN",
|
||||||
|
"type": "NODES",
|
||||||
|
"showViewport": true,
|
||||||
|
"showRender": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evaluated_instances": 38,
|
||||||
|
"repro": "host object with Distribute->InstanceOnPoints: snapshot mesh.vertexCount=0, no instance count, no positions"
|
||||||
|
},
|
||||||
|
"no_seed_parity": {
|
||||||
|
"repro": "Distribute Points on Faces seed=7 density=4 on a 3x3 grid: positions are Blender's hash; only reading them back (not offered) reproduces them"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
722
surfaces/blender/tests/test_node_actions.py
Normal file
722
surfaces/blender/tests/test_node_actions.py
Normal file
|
|
@ -0,0 +1,722 @@
|
||||||
|
"""Node-graph action tests. No Blender: a fake bpy with just enough node-tree surface.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s surfaces/blender/tests -v
|
||||||
|
The real API is exercised by tests/live/gn_actions.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
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
|
||||||
|
from brokkr_bridge import node_actions as na # noqa: E402
|
||||||
|
|
||||||
|
NS = types.SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
class Sock:
|
||||||
|
def __init__(self, identifier, name, kind="VALUE", default=0.0):
|
||||||
|
self.identifier, self.name, self.type, self.enabled = identifier, name, kind, True
|
||||||
|
self.default_value, self.is_linked = default, False
|
||||||
|
|
||||||
|
|
||||||
|
class Node(dict):
|
||||||
|
"""A node; ID properties are the dict part."""
|
||||||
|
|
||||||
|
counter = 1000
|
||||||
|
|
||||||
|
def as_pointer(self):
|
||||||
|
return self.pointer
|
||||||
|
|
||||||
|
PROPS = {"operation": NS(type="ENUM", is_readonly=False), "flag": NS(type="BOOLEAN", is_readonly=False),
|
||||||
|
"filepath": NS(type="STRING", is_readonly=False), "location": NS(type="FLOAT", is_readonly=False)}
|
||||||
|
|
||||||
|
def __init__(self, idname):
|
||||||
|
super().__init__()
|
||||||
|
self.bl_idname, self.location, self.label, self.operation, self.flag = idname, [0, 0], "", "ADD", False
|
||||||
|
Node.counter += 1
|
||||||
|
self.pointer = Node.counter
|
||||||
|
self.bl_rna = NS(properties=self.PROPS)
|
||||||
|
self.inputs = [Sock("A", "A"), Sock("Size", "Size", "VECTOR", (1.0, 1.0, 1.0)), Sock("B", "Dup"), Sock("B_VEC", "Dup")]
|
||||||
|
self.outputs = [Sock("Out", "Out")]
|
||||||
|
for socket in self.inputs + self.outputs:
|
||||||
|
socket.node = self
|
||||||
|
|
||||||
|
def __setattr__(self, key, value):
|
||||||
|
if key == "operation" and value not in ("ADD", "MULTIPLY"):
|
||||||
|
raise TypeError("enum not found")
|
||||||
|
object.__setattr__(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
class Nodes(list):
|
||||||
|
def new(self, idname):
|
||||||
|
if idname.endswith("Missing"):
|
||||||
|
raise RuntimeError("Node type undefined")
|
||||||
|
node = Node(idname)
|
||||||
|
self.append(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def remove(self, node):
|
||||||
|
list.remove(self, node)
|
||||||
|
|
||||||
|
|
||||||
|
class Proxy:
|
||||||
|
"""A fresh wrapper per access, as bpy hands out: equal by pointer, with a different id() each time."""
|
||||||
|
|
||||||
|
def __init__(self, node):
|
||||||
|
self._node = node
|
||||||
|
|
||||||
|
def as_pointer(self):
|
||||||
|
return self._node.pointer
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._node, name)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return getattr(other, "pointer", None) == self._node.pointer or (isinstance(other, Proxy) and other._node is self._node)
|
||||||
|
|
||||||
|
__hash__ = None
|
||||||
|
|
||||||
|
|
||||||
|
class Links(list):
|
||||||
|
def __init__(self, valid=True):
|
||||||
|
super().__init__()
|
||||||
|
self.valid = valid
|
||||||
|
|
||||||
|
def new(self, a, b):
|
||||||
|
for old in [l for l in self if l.to_socket is b]: # a single-link input loses its old link, as in Blender
|
||||||
|
list.remove(self, old)
|
||||||
|
link = NS(from_socket=a, to_socket=b, from_node=Proxy(a.node), to_node=Proxy(b.node), is_muted=False, is_valid=self.valid(a, b) if callable(self.valid) else self.valid)
|
||||||
|
self.append(link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
def remove(self, link):
|
||||||
|
list.remove(self, link)
|
||||||
|
|
||||||
|
|
||||||
|
class Interface:
|
||||||
|
def __init__(self):
|
||||||
|
self.items_tree = []
|
||||||
|
|
||||||
|
def new_socket(self, name, in_out, socket_type):
|
||||||
|
item = NS(item_type="SOCKET", name=name, in_out=in_out, socket_type=socket_type, identifier=f"Socket_{len(self.items_tree)}", default_value=None)
|
||||||
|
self.items_tree.append(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
class Tree:
|
||||||
|
bl_idname = "GeometryNodeTree"
|
||||||
|
library = None
|
||||||
|
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name, self.nodes, self.links, self.interface = name, Nodes(), Links(), Interface()
|
||||||
|
|
||||||
|
|
||||||
|
class Groups(dict):
|
||||||
|
def new(self, name, kind):
|
||||||
|
self[name] = Tree(name)
|
||||||
|
return self[name]
|
||||||
|
|
||||||
|
def remove(self, tree):
|
||||||
|
del self[tree.name]
|
||||||
|
|
||||||
|
|
||||||
|
class Inputs:
|
||||||
|
def __init__(self):
|
||||||
|
self.values = {}
|
||||||
|
|
||||||
|
def __getattr__(self, identifier):
|
||||||
|
return self.values.setdefault(identifier, NS(value=None))
|
||||||
|
|
||||||
|
|
||||||
|
class Modifiers(list):
|
||||||
|
refuses = False
|
||||||
|
|
||||||
|
def get(self, name):
|
||||||
|
return next((m for m in self if m.name == name), None)
|
||||||
|
|
||||||
|
def remove(self, modifier):
|
||||||
|
list.remove(self, modifier)
|
||||||
|
|
||||||
|
def new(self, name, kind):
|
||||||
|
if self.refuses:
|
||||||
|
return None # Blender returns None for object types without modifiers
|
||||||
|
m = NS(name=name, type=kind, node_group=None, properties=NS(inputs=Inputs()))
|
||||||
|
self.append(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class Obj:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name, self.type, self.data, self.modifiers, self.tagged = name, "MESH", None, Modifiers(), 0
|
||||||
|
|
||||||
|
def update_tag(self):
|
||||||
|
self.tagged += 1
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
self.custom = (key, value)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBpy:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = NS(node_groups=Groups(), objects={"Host": Obj("Host")})
|
||||||
|
|
||||||
|
|
||||||
|
class NodeActionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.bpy = FakeBpy()
|
||||||
|
self.target = bt.BrokkrBlenderTarget(self.bpy)
|
||||||
|
self.n = 0
|
||||||
|
|
||||||
|
def run_action(self, **command):
|
||||||
|
self.n += 1
|
||||||
|
command.setdefault("commandId", f"c{self.n}")
|
||||||
|
return self.target.execute_command(None, command)
|
||||||
|
|
||||||
|
def accepted(self, **command):
|
||||||
|
receipt = self.run_action(**command)
|
||||||
|
self.assertEqual(receipt["status"], "accepted", receipt["message"])
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
def refused(self, **command):
|
||||||
|
receipt = self.run_action(**command)
|
||||||
|
self.assertEqual(receipt["status"], "failed", receipt["message"])
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
def group(self, interface=None):
|
||||||
|
self.accepted(action="createNodeGroup", treeKey="G", interface=interface or [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}, {"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
|
||||||
|
return self.bpy.data.node_groups["G"]
|
||||||
|
|
||||||
|
# createNodeGroup
|
||||||
|
def test_group_gets_interface_and_stamped_group_nodes(self):
|
||||||
|
receipt = self.accepted(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
|
||||||
|
tree = self.bpy.data.node_groups["G"]
|
||||||
|
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout"])
|
||||||
|
self.assertEqual(tree.interface.items_tree[0].default_value, 8)
|
||||||
|
self.assertIn("Count=Socket_0", receipt["message"])
|
||||||
|
|
||||||
|
def test_group_refusals_create_nothing(self):
|
||||||
|
for interface in (None, [], "x", [{"inOut": "SIDEWAYS", "name": "a", "type": "Int"}], [{"inOut": "INPUT", "name": "a", "type": "Mesh"}],
|
||||||
|
[{"inOut": "INPUT", "name": "a", "type": "Int"}, {"inOut": "INPUT", "name": "a", "type": "Float"}],
|
||||||
|
[{"inOut": "INPUT", "name": "a", "type": "Int", "default": 1.5}], [{"inOut": "INPUT", "name": "a", "type": "Vector", "default": [1, 2]}]):
|
||||||
|
self.refused(action="createNodeGroup", treeKey="G", interface=interface)
|
||||||
|
self.refused(action="createNodeGroup", treeKey="../x", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
self.refused(action="createNodeGroup", treeKind="material", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
self.assertEqual(len(self.bpy.data.node_groups), 0)
|
||||||
|
|
||||||
|
def test_group_that_exists_is_not_overwritten(self):
|
||||||
|
self.group()
|
||||||
|
self.refused(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
self.assertEqual(len(self.bpy.data.node_groups["G"].interface.items_tree), 2)
|
||||||
|
|
||||||
|
# addNode
|
||||||
|
def test_add_node_applies_properties_then_inputs_and_stamps_id(self):
|
||||||
|
tree = self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="m", blIdname="ShaderNodeMath", location=[10, 20], properties={"operation": "MULTIPLY"}, inputs={"A": 3, "Size": [1, 2, 3]})
|
||||||
|
node = tree.nodes[-1]
|
||||||
|
self.assertEqual((node.operation, node.location, node.inputs[0].default_value, node.inputs[1].default_value), ("MULTIPLY", [10.0, 20.0], 3.0, (1.0, 2.0, 3.0)))
|
||||||
|
|
||||||
|
def test_add_node_refusals_leave_no_node(self):
|
||||||
|
tree = self.group()
|
||||||
|
before = len(tree.nodes)
|
||||||
|
cases = [dict(blIdname="GeometryNodeImportOBJ"), dict(blIdname="GeometryNodeBake"), dict(blIdname="NodeGroupInput"), dict(blIdname="bpy.ops"),
|
||||||
|
dict(blIdname="ShaderNodeMissing"), dict(blIdname="ShaderNodeMath", properties={"operation": "NOPE"}),
|
||||||
|
dict(blIdname="ShaderNodeMath", properties={"filepath": "/etc/passwd"}), dict(blIdname="ShaderNodeMath", properties={"location": 1.0}),
|
||||||
|
dict(blIdname="ShaderNodeMath", properties={"missing": 1}), dict(blIdname="ShaderNodeMath", inputs={"Dup": 1}),
|
||||||
|
dict(blIdname="ShaderNodeMath", inputs={"Nope": 1}), dict(blIdname="ShaderNodeMath", inputs={"A": float("nan")}),
|
||||||
|
dict(blIdname="ShaderNodeMath", inputs={"A": True}), dict(blIdname="ShaderNodeMath", inputs={"A": 1e12}),
|
||||||
|
dict(blIdname="ShaderNodeMath", inputs={"Size": [1, 2]}), dict(blIdname="ShaderNodeMath", location=[1])]
|
||||||
|
for case in cases:
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", **case)
|
||||||
|
self.assertEqual(len(tree.nodes), before)
|
||||||
|
|
||||||
|
def test_string_and_reserved_properties_are_refused_by_rule(self):
|
||||||
|
self.group()
|
||||||
|
for name in ("filepath", "location"):
|
||||||
|
receipt = self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={name: "v"})
|
||||||
|
self.assertIn("no settable property", receipt["message"])
|
||||||
|
|
||||||
|
def test_add_node_refuses_bad_ids_duplicates_and_missing_tree(self):
|
||||||
|
self.group()
|
||||||
|
for bad in ("", "a b", "x" * 64, "../x", None):
|
||||||
|
self.refused(action="addNode", treeKey="G", id=bad, blIdname="ShaderNodeMath")
|
||||||
|
self.refused(action="addNode", treeKey="G", id="gin", blIdname="ShaderNodeMath")
|
||||||
|
self.refused(action="addNode", treeKey="Nope", id="a", blIdname="ShaderNodeMath")
|
||||||
|
|
||||||
|
def test_node_cap(self):
|
||||||
|
tree = self.group()
|
||||||
|
for i in range(na.MAX_NODES - 2):
|
||||||
|
tree.nodes.new("ShaderNodeMath")
|
||||||
|
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
|
||||||
|
|
||||||
|
def test_linked_library_group_is_read_only(self):
|
||||||
|
tree = self.group()
|
||||||
|
tree.library = NS(filepath="lib.blend")
|
||||||
|
self.refused(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
|
||||||
|
|
||||||
|
# access rules, pinned by literals
|
||||||
|
def test_each_denied_node_family_is_refused_alone(self):
|
||||||
|
tree = self.group()
|
||||||
|
denied = ["GeometryNodeImportOBJ", "GeometryNodeExportPLY", "ShaderNodeScript", "GeometryNodeBake", "GeometryNodeGroup",
|
||||||
|
"GeometryNodeSimulationInput", "GeometryNodeRepeatInput", "GeometryNodeForeachGeometryElementInput",
|
||||||
|
"GeometryNodeViewer", "ShaderNodeTexImage", "ShaderNodeTexEnvironment", "GeometryNodeImageTexture",
|
||||||
|
"GeometryNodeInputImage", "ShaderNodeTexIES"]
|
||||||
|
before = len(tree.nodes)
|
||||||
|
for idname in denied:
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", blIdname=idname)
|
||||||
|
self.assertEqual(len(tree.nodes), before)
|
||||||
|
|
||||||
|
def test_legitimate_node_families_are_allowed(self):
|
||||||
|
for i, idname in enumerate(["ShaderNodeMath", "GeometryNodeMeshGrid", "FunctionNodeCompare", "GeometryNodeSetPosition", "ShaderNodeTexNoise"]):
|
||||||
|
self.group() if i == 0 else None
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=f"n{i}", blIdname=idname)
|
||||||
|
|
||||||
|
def test_idname_must_be_a_whole_known_prefixed_name(self):
|
||||||
|
self.group()
|
||||||
|
for bad in ("Foo", "ShaderNode", "xShaderNodeMath", "ShaderNodeMath\n", "ShaderNode Math", "ShaderNode_Math", "CompositorNodeMath", "ShaderNodeMath;", "", None, 5):
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", blIdname=bad)
|
||||||
|
|
||||||
|
def test_node_cap_is_exactly_500(self):
|
||||||
|
tree = self.group()
|
||||||
|
for _ in range(497):
|
||||||
|
tree.nodes.new("ShaderNodeMath")
|
||||||
|
self.assertEqual(len(tree.nodes), 499)
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="last", blIdname="ShaderNodeMath")
|
||||||
|
self.assertEqual(len(tree.nodes), 500)
|
||||||
|
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
|
||||||
|
|
||||||
|
def test_interface_cap_is_exactly_64(self):
|
||||||
|
def sockets(count):
|
||||||
|
return [{"inOut": "INPUT", "name": f"s{i}", "type": "Int"} for i in range(count)]
|
||||||
|
self.accepted(action="createNodeGroup", treeKey="A", interface=sockets(64))
|
||||||
|
self.refused(action="createNodeGroup", treeKey="B", interface=sockets(65))
|
||||||
|
self.refused(action="createNodeGroup", treeKey="C", interface=[])
|
||||||
|
|
||||||
|
def test_int_limit_is_exactly_4096(self):
|
||||||
|
self.group()
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
for value in (4096, -4096):
|
||||||
|
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
|
||||||
|
for value in (4097, -4097):
|
||||||
|
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath", inputs={"A": 4097}) # a float input has no int cap
|
||||||
|
|
||||||
|
def test_value_limit_is_exactly_one_billion(self):
|
||||||
|
self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath", inputs={"A": 1e9})
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath", inputs={"A": -1e9})
|
||||||
|
self.refused(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath", inputs={"A": 1000000001})
|
||||||
|
self.refused(action="addNode", treeKey="G", id="d", blIdname="ShaderNodeMath", inputs={"Size": [0, 0, -1000000001]})
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="e", blIdname="ShaderNodeMath", location=[1e9, -1e9])
|
||||||
|
self.refused(action="addNode", treeKey="G", id="f", blIdname="ShaderNodeMath", location=[1000000001, 0])
|
||||||
|
|
||||||
|
def test_modifier_cap_is_exactly_8(self):
|
||||||
|
self.group()
|
||||||
|
for i in range(8):
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
|
||||||
|
self.refused(action="addModifier", objectName="Host", modifierName="m8", treeKey="G")
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="m3", treeKey="G") # reuse is not growth
|
||||||
|
|
||||||
|
def test_location_must_be_two_finite_numbers(self):
|
||||||
|
tree = self.group()
|
||||||
|
for bad in ([1, 2, 3], "ab", 5, [1, float("nan")], [1, "2"], [True, 1]):
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", location=bad)
|
||||||
|
self.assertEqual(len(tree.nodes), 2)
|
||||||
|
|
||||||
|
def test_readonly_node_property_is_refused_and_untouched(self):
|
||||||
|
self.group()
|
||||||
|
with unittest.mock.patch.dict(Node.PROPS, {"flag": NS(type="BOOLEAN", is_readonly=True)}):
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={"flag": True})
|
||||||
|
self.assertEqual(len(self.bpy.data.node_groups["G"].nodes), 2)
|
||||||
|
|
||||||
|
def test_input_without_a_default_or_with_a_link_is_refused(self):
|
||||||
|
tree = self.group()
|
||||||
|
original = Nodes.new
|
||||||
|
|
||||||
|
def linked(nodes, idname):
|
||||||
|
node = original(nodes, idname)
|
||||||
|
node.inputs[0].is_linked = True
|
||||||
|
return node
|
||||||
|
with unittest.mock.patch.object(Nodes, "new", linked):
|
||||||
|
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", inputs={"A": 1})
|
||||||
|
self.assertEqual(len(tree.nodes), 2)
|
||||||
|
|
||||||
|
def bare(nodes, idname):
|
||||||
|
node = original(nodes, idname)
|
||||||
|
del node.inputs[0].default_value
|
||||||
|
return node
|
||||||
|
with unittest.mock.patch.object(Nodes, "new", bare):
|
||||||
|
self.refused(action="addNode", treeKey="G", id="y", blIdname="ShaderNodeMath", inputs={"A": 1})
|
||||||
|
self.assertEqual(len(tree.nodes), 2)
|
||||||
|
|
||||||
|
def test_create_node_group_that_fails_midway_leaves_no_group(self):
|
||||||
|
original = Nodes.new
|
||||||
|
|
||||||
|
def failing(nodes, idname):
|
||||||
|
if idname == "NodeGroupOutput":
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return original(nodes, idname)
|
||||||
|
with unittest.mock.patch.object(Nodes, "new", failing):
|
||||||
|
self.refused(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
self.assertNotIn("Half", self.bpy.data.node_groups)
|
||||||
|
self.accepted(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
|
||||||
|
def test_add_modifier_will_not_take_over_a_foreign_modifier(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
foreign = host.modifiers.new("Mine", "SUBSURF")
|
||||||
|
self.refused(action="addModifier", objectName="Host", modifierName="Mine", treeKey="G")
|
||||||
|
self.assertIsNone(foreign.node_group)
|
||||||
|
self.assertEqual(len(host.modifiers), 1)
|
||||||
|
|
||||||
|
def test_set_modifier_input_only_drives_geometry_nodes_modifiers(self):
|
||||||
|
tree = self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
foreign = host.modifiers.new("Mine", "SUBSURF")
|
||||||
|
foreign.node_group = tree
|
||||||
|
self.refused(action="setModifierInput", objectName="Host", modifierName="Mine", socket="Count", value=1)
|
||||||
|
self.assertEqual(foreign.properties.inputs.values, {})
|
||||||
|
self.assertEqual(host.tagged, 0)
|
||||||
|
|
||||||
|
def test_blender_capabilities_include_everything_the_daemon_advertises_for_this_cut(self):
|
||||||
|
# brokkr-daemon/src/main.rs lists the same names (and asserts them in its own test); a capability
|
||||||
|
# added to one side only fails one of the two tests.
|
||||||
|
for name in ("asset.import.gltf", "camera.write", "light.write", "world.write", "render.still", "nodegraph.geometry.write", "geometry.evaluated.read"):
|
||||||
|
self.assertIn(name, bt.CAPABILITIES)
|
||||||
|
|
||||||
|
# linkSockets
|
||||||
|
def test_link_by_identifier_and_by_unique_name(self):
|
||||||
|
tree = self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Size")
|
||||||
|
self.assertEqual(len(tree.links), 2)
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup") # name shared by two sockets
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="zzz", toSocket="A")
|
||||||
|
self.assertEqual(len(tree.links), 2)
|
||||||
|
|
||||||
|
def test_unavailable_socket_is_not_addressable_by_name(self):
|
||||||
|
tree = self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
|
||||||
|
tree.nodes[-1].inputs[3].enabled = False
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup")
|
||||||
|
|
||||||
|
def test_invalid_link_is_refused_and_removed(self):
|
||||||
|
tree = self.group()
|
||||||
|
tree.links.valid = False
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
|
||||||
|
receipt = self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
|
||||||
|
self.assertIn("not valid", receipt["message"]) # past the cycle check: Blender was asked and said no
|
||||||
|
self.assertEqual(len(tree.links), 0)
|
||||||
|
|
||||||
|
# addModifier / setModifierInput
|
||||||
|
def test_modifier_assigns_group_and_reuses_by_name(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
self.assertEqual(len(host.modifiers), 1)
|
||||||
|
self.assertIs(host.modifiers[0].node_group, self.bpy.data.node_groups["G"])
|
||||||
|
self.refused(action="addModifier", objectName="Host", modifierType="SUBSURF", treeKey="G")
|
||||||
|
self.refused(action="addModifier", objectName="Nope", treeKey="G")
|
||||||
|
self.refused(action="addModifier", objectName="Host", treeKey="Nope")
|
||||||
|
|
||||||
|
def test_modifier_cap(self):
|
||||||
|
self.group()
|
||||||
|
for i in range(na.MAX_MODIFIERS):
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
|
||||||
|
self.refused(action="addModifier", objectName="Host", modifierName="extra", treeKey="G")
|
||||||
|
|
||||||
|
def test_set_modifier_input_writes_value_through_the_struct_and_tags(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
tags = host.tagged
|
||||||
|
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=12)
|
||||||
|
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 12)
|
||||||
|
self.assertEqual(host.tagged, tags + 1)
|
||||||
|
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=3)
|
||||||
|
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 3)
|
||||||
|
|
||||||
|
def test_set_modifier_input_refusals_change_nothing(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
for socket, value in (("Count", 1.5), ("Count", "x"), ("Count", True), ("Count", None), ("Count", 1e12), ("Nope", 1), ("Geometry", 1), ("", 1)):
|
||||||
|
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket=socket, value=value)
|
||||||
|
self.refused(action="setModifierInput", objectName="Host", modifierName="other", socket="Count", value=1)
|
||||||
|
self.assertEqual(host.modifiers[0].properties.inputs.values, {})
|
||||||
|
|
||||||
|
def test_set_modifier_input_by_name_needs_a_unique_name(self):
|
||||||
|
tree = self.group([{"inOut": "INPUT", "name": "Dup", "type": "Int"}])
|
||||||
|
tree.interface.new_socket("Dup", in_out="INPUT", socket_type="NodeSocketFloat") # a hand-made group can repeat a name
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Dup", value=1)
|
||||||
|
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=1.5)
|
||||||
|
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 1.5)
|
||||||
|
|
||||||
|
# removal, unlinking, cycles
|
||||||
|
def chain(self):
|
||||||
|
tree = self.group()
|
||||||
|
for node_id in ("a", "b", "c"):
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def test_cycles_and_self_links_are_refused_and_change_nothing(self):
|
||||||
|
tree = self.chain()
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
before = list(tree.links)
|
||||||
|
for src, dst in (("c", "a"), ("b", "a"), ("a", "a"), ("c", "b")):
|
||||||
|
receipt = self.refused(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="Size")
|
||||||
|
self.assertIn("cycle", receipt["message"])
|
||||||
|
self.assertEqual(list(tree.links), before)
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="Size") # a forward skip is no cycle
|
||||||
|
|
||||||
|
def test_cycle_detection_survives_fresh_node_wrappers_on_long_chains(self):
|
||||||
|
tree = self.group()
|
||||||
|
ids = [f"n{i}" for i in range(12)]
|
||||||
|
for node_id in ids:
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
|
||||||
|
for a, b in zip(ids, ids[1:]):
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="A")
|
||||||
|
# a diamond ladder: every rung reachable two ways
|
||||||
|
for a, b in zip(ids[:-2], ids[2:]):
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="Size")
|
||||||
|
count = len(tree.links)
|
||||||
|
for head in ids[:-1]:
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode=ids[-1], fromSocket="Out", toNode=head, toSocket="Size")
|
||||||
|
self.assertEqual(len(tree.links), count)
|
||||||
|
|
||||||
|
def test_cycle_through_a_later_branch_is_found(self):
|
||||||
|
tree = self.group()
|
||||||
|
for node_id in "abcd":
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
|
||||||
|
for src, dst in (("a", "b"), ("a", "c"), ("c", "d")):
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="A")
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode="d", fromSocket="Out", toNode="a", toSocket="A")
|
||||||
|
|
||||||
|
def test_cycle_walk_visits_each_node_once_on_a_ladder(self):
|
||||||
|
tree = self.group()
|
||||||
|
ids = [f"n{i}" for i in range(40)]
|
||||||
|
for node_id in ids:
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
|
||||||
|
for a, b in zip(ids, ids[1:]):
|
||||||
|
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[0])
|
||||||
|
for a, b in zip(ids, ids[2:]):
|
||||||
|
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[1])
|
||||||
|
calls = []
|
||||||
|
real = na.node_key
|
||||||
|
with unittest.mock.patch.object(na, "node_key", lambda n: calls.append(1) or real(n)):
|
||||||
|
self.assertFalse(na.reaches(tree, tree.nodes[2], NS(pointer=-1, as_pointer=lambda: -1)))
|
||||||
|
self.assertLess(len(calls), 4 * len(tree.links))
|
||||||
|
|
||||||
|
def test_remove_node(self):
|
||||||
|
tree = self.chain()
|
||||||
|
self.accepted(action="removeNode", treeKey="G", id="b")
|
||||||
|
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout", "a", "c"])
|
||||||
|
again = self.accepted(action="removeNode", treeKey="G", id="b")
|
||||||
|
self.assertIn("already absent", again["message"])
|
||||||
|
for keep in ("gin", "gout"):
|
||||||
|
self.refused(action="removeNode", treeKey="G", id=keep)
|
||||||
|
self.refused(action="removeNode", treeKey="Nope", id="a")
|
||||||
|
self.refused(action="removeNode", treeKey="G", id="a b")
|
||||||
|
|
||||||
|
def test_unlink_removes_only_the_matching_links(self):
|
||||||
|
tree = self.chain()
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="Size")
|
||||||
|
self.refused(action="unlinkSockets", treeKey="G", fromNode="zzz", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
self.assertIn("Removed 0", self.accepted(action="unlinkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")["message"])
|
||||||
|
self.assertEqual(len(tree.links), 2)
|
||||||
|
self.assertIn("Removed 1", self.accepted(action="unlinkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")["message"])
|
||||||
|
self.assertEqual(len(tree.links), 1)
|
||||||
|
self.accepted(action="unlinkSockets", treeKey="G", toNode="c", toSocket="Size")
|
||||||
|
self.assertEqual(len(tree.links), 0)
|
||||||
|
self.refused(action="unlinkSockets", treeKey="G", fromNode="a", toNode="c", toSocket="A") # a source node needs its socket
|
||||||
|
|
||||||
|
def test_remove_modifier_takes_only_geometry_nodes_modifiers(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
host.modifiers.new("Sub", "SUBSURF")
|
||||||
|
tags = host.tagged
|
||||||
|
self.refused(action="removeModifier", objectName="Host", modifierName="Sub")
|
||||||
|
self.assertEqual([m.name for m in host.modifiers], ["GN", "Sub"])
|
||||||
|
self.accepted(action="removeModifier", objectName="Host", modifierName="GN")
|
||||||
|
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
|
||||||
|
self.assertEqual(host.tagged, tags + 1)
|
||||||
|
self.assertIn("already absent", self.accepted(action="removeModifier", objectName="Host", modifierName="GN")["message"])
|
||||||
|
self.refused(action="removeModifier", objectName="Nope", modifierName="GN")
|
||||||
|
for bad in (["x"], "x" * 256, "a\0b", 5):
|
||||||
|
self.refused(action="removeModifier", objectName="Host", modifierName=bad)
|
||||||
|
host.modifiers.new("Gé x/y", "NODES") # made by hand: any name Blender allows can be removed
|
||||||
|
self.accepted(action="removeModifier", objectName="Host", modifierName="Gé x/y")
|
||||||
|
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
|
||||||
|
|
||||||
|
# setObjectCustomProperty on a Geometry Nodes host
|
||||||
|
def test_custom_property_naming_a_modifier_input_identifier_is_refused(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
for name in ("Socket_1", "customProperties.Socket_1"):
|
||||||
|
receipt = self.refused(action="setObjectCustomProperty", targetObjectName="Host", propertyName=name, value=3)
|
||||||
|
self.assertIn("setModifierInput", receipt["message"])
|
||||||
|
self.assertFalse(hasattr(host, "custom"))
|
||||||
|
|
||||||
|
def test_custom_property_sharing_only_a_display_name_is_an_ordinary_property(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="Count", value=3)
|
||||||
|
self.assertEqual(host.custom, ("Count", 3))
|
||||||
|
|
||||||
|
def test_other_custom_properties_on_the_same_object_still_work(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
|
||||||
|
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="tag", value=3)
|
||||||
|
self.assertEqual(host.custom, ("tag", 3))
|
||||||
|
|
||||||
|
# review fixes
|
||||||
|
def test_refused_link_restores_the_link_it_displaced(self):
|
||||||
|
tree = self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
tree.links.valid = lambda a, b: a is not tree.nodes[3].outputs[0] # b.Out -> anything is invalid
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
self.assertEqual([(l.from_socket, l.to_socket) for l in tree.links], [(tree.nodes[2].outputs[0], tree.nodes[4].inputs[0])])
|
||||||
|
|
||||||
|
def test_refused_link_restores_the_displaced_link_muted_state(self):
|
||||||
|
tree = self.group()
|
||||||
|
for node_id in ("a", "b", "c"):
|
||||||
|
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
|
||||||
|
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
tree.links[0].is_muted = True
|
||||||
|
tree.links.valid = lambda a, b: False
|
||||||
|
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
|
||||||
|
self.assertEqual([l.is_muted for l in tree.links], [True])
|
||||||
|
|
||||||
|
def test_ints_are_capped_and_patterns_reject_trailing_newlines(self):
|
||||||
|
self.group()
|
||||||
|
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath")
|
||||||
|
self.refused(action="addNode", treeKey="G", id="a\n", blIdname="ShaderNodeMath")
|
||||||
|
self.refused(action="createNodeGroup", treeKey="T\n", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
|
||||||
|
self.refused(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT + 1}])
|
||||||
|
self.accepted(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT}])
|
||||||
|
|
||||||
|
def test_modifier_on_an_object_type_without_modifiers_is_a_clear_refusal(self):
|
||||||
|
self.group()
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
host.modifiers.refuses = True
|
||||||
|
host.type = "CAMERA"
|
||||||
|
receipt = self.refused(action="addModifier", objectName="Host", treeKey="G")
|
||||||
|
self.assertIn("CAMERA", receipt["message"])
|
||||||
|
|
||||||
|
# snapshot
|
||||||
|
def snapshot_fixture(self, coords, instances):
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
host.modifiers.new("GN", "NODES")
|
||||||
|
|
||||||
|
class Verts(list):
|
||||||
|
def foreach_get(self_inner, name, buffer):
|
||||||
|
flat = [c for v in self_inner for c in v.co]
|
||||||
|
buffer[:] = type(buffer)("f", flat)
|
||||||
|
|
||||||
|
mesh = NS(vertices=Verts(NS(co=c) for c in coords), edges=[1], polygons=[])
|
||||||
|
evaluated = NS(to_mesh=lambda: mesh, to_mesh_clear=lambda: None)
|
||||||
|
host.evaluated_get = lambda dg: evaluated
|
||||||
|
self.bpy.context = NS(evaluated_depsgraph_get=lambda: NS(update=lambda: None, object_instances=instances))
|
||||||
|
return host
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def instance(parent, point, is_instance=True):
|
||||||
|
return NS(is_instance=is_instance, parent=NS(original=parent) if parent else None, matrix_world=NS(translation=point))
|
||||||
|
|
||||||
|
def evaluated(self, host):
|
||||||
|
return self.target._evaluated_snapshot(host, self.target._instance_scan())
|
||||||
|
|
||||||
|
def test_evaluated_snapshot_reports_counts_separate_bounds_and_cap(self):
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
n = bt.EVALUATED_POSITION_CAP + 2
|
||||||
|
other = Obj("other")
|
||||||
|
instances = [self.instance(host, (0.0, 5.0 + i, 0.0)) for i in range(n)] + [self.instance(other, (9.0, 9.0, 9.0)), self.instance(None, (8.0, 8.0, 8.0), False)]
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)], instances)
|
||||||
|
data = self.evaluated(host)
|
||||||
|
self.assertEqual((data["vertexCount"], data["edgeCount"], data["instanceCount"]), (3, 1, n))
|
||||||
|
self.assertEqual((data["vertexBoundsMin"], data["vertexBoundsMax"]), ([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]))
|
||||||
|
self.assertEqual((data["instanceBoundsMin"], data["instanceBoundsMax"]), ([0.0, 5.0, 0.0], [0.0, 5.0 + n - 1, 0.0]))
|
||||||
|
self.assertEqual((len(data["instanceLocations"]), len(data["vertexLocations"]), data["truncated"]), (bt.EVALUATED_POSITION_CAP, 3, True))
|
||||||
|
self.assertFalse(data["nonFinite"])
|
||||||
|
|
||||||
|
def test_non_finite_positions_are_flagged_not_published(self):
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (float("inf"), 0.0, 0.0)], [])
|
||||||
|
data = self.evaluated(host)
|
||||||
|
self.assertTrue(data["nonFinite"])
|
||||||
|
self.assertEqual((data["vertexLocations"], data["vertexBoundsMin"], data["vertexBoundsMax"]), ([], [], []))
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (float("nan"), 0.0, 0.0))])
|
||||||
|
data = self.evaluated(host)
|
||||||
|
self.assertTrue(data["nonFinite"] and data["instanceLocations"] == [] and data["instanceBoundsMin"] == [])
|
||||||
|
|
||||||
|
def test_a_huge_evaluated_mesh_reports_counts_only(self):
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0)] * 3, [])
|
||||||
|
with unittest.mock.patch.object(bt, "EVALUATED_VERTEX_LIMIT", 2):
|
||||||
|
data = self.evaluated(host)
|
||||||
|
self.assertEqual((data["vertexCount"], data["positionsSkipped"], data["vertexLocations"], data["vertexBoundsMin"]), (3, True, [], []))
|
||||||
|
|
||||||
|
def test_per_object_snapshots_reuse_the_one_scan(self):
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [])
|
||||||
|
calls = []
|
||||||
|
get = self.bpy.context.evaluated_depsgraph_get
|
||||||
|
self.bpy.context.evaluated_depsgraph_get = lambda: calls.append(1) or get()
|
||||||
|
scan = self.target._instance_scan()
|
||||||
|
for _ in range(3):
|
||||||
|
self.target._evaluated_snapshot(host, scan)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_instance_scan_stops_at_the_limit_and_says_so(self):
|
||||||
|
host = self.bpy.data.objects["Host"]
|
||||||
|
visited = []
|
||||||
|
|
||||||
|
def many():
|
||||||
|
for i in range(50):
|
||||||
|
visited.append(i)
|
||||||
|
yield self.instance(host, (float(i), 0.0, 0.0))
|
||||||
|
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], many())
|
||||||
|
with unittest.mock.patch.object(bt, "EVALUATED_INSTANCE_SCAN_LIMIT", 10):
|
||||||
|
scan = self.target._instance_scan()
|
||||||
|
data = self.target._evaluated_snapshot(host, scan)
|
||||||
|
self.assertEqual((data["instanceCount"], data["instancesComplete"]), (10, False))
|
||||||
|
other = Obj("Other")
|
||||||
|
other.modifiers.new("GN", "NODES")
|
||||||
|
other.evaluated_get = host.evaluated_get
|
||||||
|
self.assertIsNone(self.target._evaluated_snapshot(other, scan)["instanceCount"]) # never reached: unknown, not 0
|
||||||
|
self.assertLessEqual(len(visited), 11) # the generator is abandoned, not drained
|
||||||
|
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (1.0, 0.0, 0.0))])
|
||||||
|
self.assertTrue(self.evaluated(host)["instancesComplete"])
|
||||||
|
|
||||||
|
def test_object_without_modifiers_has_no_evaluated_block(self):
|
||||||
|
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], None))
|
||||||
|
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], (None, {}, True)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -86,7 +86,27 @@ class FakeBpy:
|
||||||
worlds=NS(get=lambda name: None, new=self.new_world),
|
worlds=NS(get=lambda name: None, new=self.new_world),
|
||||||
)
|
)
|
||||||
self.path = NS(abspath=lambda p: p)
|
self.path = NS(abspath=lambda p: p)
|
||||||
self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still))
|
self.created = []
|
||||||
|
self.app = NS(background=False)
|
||||||
|
self.exported = []
|
||||||
|
self.export_result = {"FINISHED"}
|
||||||
|
self.export_writes = True
|
||||||
|
for o in self.data.objects.values():
|
||||||
|
o.selected = o is self.mesh
|
||||||
|
o.select_get = (lambda o=o: o.selected)
|
||||||
|
o.select_set = (lambda state, o=o: setattr(o, "selected", state))
|
||||||
|
add = lambda kind: (lambda **kw: self.add_object(kind, kw))
|
||||||
|
self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still),
|
||||||
|
export_scene=NS(gltf=self.export_gltf),
|
||||||
|
mesh=NS(primitive_cube_add=add("MESH")), object=NS(light_add=add("LIGHT"),
|
||||||
|
camera_add=add("CAMERA"), empty_add=add("EMPTY")))
|
||||||
|
self.on_add = lambda obj: None
|
||||||
|
|
||||||
|
def add_object(self, kind, kwargs):
|
||||||
|
self.created.append((kind, kwargs))
|
||||||
|
obj = NS(name=kind.title(), type=kind)
|
||||||
|
self.data.objects[obj.name] = obj
|
||||||
|
self.on_add(obj)
|
||||||
|
|
||||||
def new_world(self, name):
|
def new_world(self, name):
|
||||||
background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)})
|
background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)})
|
||||||
|
|
@ -99,6 +119,13 @@ class FakeBpy:
|
||||||
self.imported.append(filepath)
|
self.imported.append(filepath)
|
||||||
self.data.objects["Imported"] = NS(name="Imported", type="MESH", data=None)
|
self.data.objects["Imported"] = NS(name="Imported", type="MESH", data=None)
|
||||||
|
|
||||||
|
def export_gltf(self, **kwargs):
|
||||||
|
kwargs["selected_at_call"] = sorted(o.name for o in self.data.objects.values() if o.selected)
|
||||||
|
self.exported.append(kwargs)
|
||||||
|
if self.export_writes:
|
||||||
|
Path(kwargs["filepath"]).write_bytes(b"x" * 10)
|
||||||
|
return self.export_result
|
||||||
|
|
||||||
def render_still(self, write_still):
|
def render_still(self, write_still):
|
||||||
r = self.scene.render
|
r = self.scene.render
|
||||||
self.rendered.append((r.engine, r.resolution_x, r.resolution_y, r.filepath,
|
self.rendered.append((r.engine, r.resolution_x, r.resolution_y, r.filepath,
|
||||||
|
|
@ -117,7 +144,9 @@ class ActionTests(unittest.TestCase):
|
||||||
self.bpy = FakeBpy()
|
self.bpy = FakeBpy()
|
||||||
self.target = bt.BrokkrBlenderTarget(self.bpy)
|
self.target = bt.BrokkrBlenderTarget(self.bpy)
|
||||||
self.target.configure_action_dirs(str(self.root / "in"), str(self.root / "out"))
|
self.target.configure_action_dirs(str(self.root / "in"), str(self.root / "out"))
|
||||||
self.context = NS(scene=self.bpy.scene)
|
self.context = NS(scene=self.bpy.scene, object=None,
|
||||||
|
view_layer=NS(objects=Objects(dict(self.bpy.data.objects.items()))))
|
||||||
|
self.bpy.on_add = lambda obj: setattr(self.context, "object", obj)
|
||||||
|
|
||||||
def run_action(self, **command):
|
def run_action(self, **command):
|
||||||
command.setdefault("commandId", "c1")
|
command.setdefault("commandId", "c1")
|
||||||
|
|
@ -206,6 +235,118 @@ class ActionTests(unittest.TestCase):
|
||||||
self.assertEqual(self.run_action(action="setWorld", color=[9, 0, 0])["status"], "failed")
|
self.assertEqual(self.run_action(action="setWorld", color=[9, 0, 0])["status"], "failed")
|
||||||
self.assertIsNone(self.bpy.scene.world)
|
self.assertIsNone(self.bpy.scene.world)
|
||||||
|
|
||||||
|
# exportGltf
|
||||||
|
def test_export_glb_defaults(self):
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="ship/a.glb")
|
||||||
|
self.assertEqual(receipt["status"], "accepted", receipt)
|
||||||
|
path = str(self.root / "out" / "ship" / "a.glb")
|
||||||
|
self.assertIn(path, receipt["message"])
|
||||||
|
self.assertIn("10 bytes", receipt["message"])
|
||||||
|
call = self.bpy.exported[0]
|
||||||
|
self.assertEqual({k: v for k, v in call.items() if k != "selected_at_call"},
|
||||||
|
{"filepath": path, "export_format": "GLB", "export_extras": True,
|
||||||
|
"export_yup": True, "export_apply": True, "use_selection": False})
|
||||||
|
|
||||||
|
def test_export_gltf_suffix_is_separate_and_options_pass_through(self):
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="a.GLTF", extras=False, yUp=False,
|
||||||
|
applyModifiers=False)
|
||||||
|
self.assertEqual(receipt["status"], "accepted", receipt)
|
||||||
|
call = self.bpy.exported[0]
|
||||||
|
self.assertEqual((call["export_format"], call["export_extras"], call["export_yup"], call["export_apply"]),
|
||||||
|
("GLTF_SEPARATE", False, False, False))
|
||||||
|
|
||||||
|
def test_export_selection_selects_exactly_those_then_restores(self):
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam", "Lamp"])
|
||||||
|
self.assertEqual(receipt["status"], "accepted", receipt)
|
||||||
|
call = self.bpy.exported[0]
|
||||||
|
self.assertTrue(call["use_selection"])
|
||||||
|
self.assertEqual(call["selected_at_call"], ["Cam", "Lamp"])
|
||||||
|
self.assertEqual([o.name for o in self.bpy.data.objects.values() if o.selected], ["Cube"])
|
||||||
|
|
||||||
|
def test_export_without_a_selection_never_touches_selection(self):
|
||||||
|
def explode(*a, **k):
|
||||||
|
raise AssertionError("selection touched")
|
||||||
|
for o in self.bpy.data.objects.values():
|
||||||
|
o.select_set = explode
|
||||||
|
o.select_get = explode
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="a.glb")
|
||||||
|
self.assertEqual(receipt["status"], "accepted", receipt)
|
||||||
|
self.assertFalse(self.bpy.exported[0]["use_selection"])
|
||||||
|
|
||||||
|
def test_export_selection_ignores_objects_outside_the_view_layer_and_refuses_naming_one(self):
|
||||||
|
def not_in_layer(state):
|
||||||
|
raise RuntimeError("Object can't be selected because it is not in View Layer")
|
||||||
|
other = NS(name="Other", type="MESH", data=None, selected=False, select_get=lambda: False, select_set=not_in_layer)
|
||||||
|
self.bpy.data.objects["Other"] = other
|
||||||
|
ok = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam"])
|
||||||
|
self.assertEqual(ok["status"], "accepted", ok)
|
||||||
|
self.assertEqual(self.bpy.exported[0]["selected_at_call"], ["Cam"])
|
||||||
|
refused = self.run_action(action="exportGltf", filepath="b.glb", selection=["Cam", "Other"])
|
||||||
|
self.assertEqual(refused["status"], "failed")
|
||||||
|
self.assertIn("Other", refused["message"])
|
||||||
|
self.assertIn("view layer", refused["message"])
|
||||||
|
self.assertEqual(len(self.bpy.exported), 1)
|
||||||
|
self.assertFalse((self.root / "out" / "b.glb").exists())
|
||||||
|
|
||||||
|
def test_export_selection_restored_when_the_exporter_raises(self):
|
||||||
|
def boom(**kw):
|
||||||
|
raise RuntimeError("exporter blew up")
|
||||||
|
self.bpy.ops.export_scene.gltf = boom
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam"])
|
||||||
|
self.assertEqual(receipt["status"], "failed")
|
||||||
|
self.assertIn("exporter blew up", receipt["message"])
|
||||||
|
self.assertEqual([o.name for o in self.bpy.data.objects.values() if o.selected], ["Cube"])
|
||||||
|
|
||||||
|
def test_export_refusals_never_reach_bpy_or_disk(self):
|
||||||
|
(self.root / "out" / "taken.glb").write_bytes(b"old")
|
||||||
|
cases = [
|
||||||
|
{"filepath": "a.obj"}, {"filepath": "a"}, {"filepath": ""}, {"filepath": 5},
|
||||||
|
{"filepath": "../a.glb"}, {"filepath": str(self.root / "a.glb")},
|
||||||
|
{"filepath": "taken.glb"},
|
||||||
|
{"filepath": "a.glb", "selection": ["Nope"]},
|
||||||
|
{"filepath": "a.glb", "selection": ["Cube", "Nope"]},
|
||||||
|
{"filepath": "a.glb", "selection": []},
|
||||||
|
{"filepath": "a.glb", "selection": "Cube"},
|
||||||
|
{"filepath": "a.glb", "selection": [3]},
|
||||||
|
{"filepath": "a.glb", "extras": "yes"},
|
||||||
|
{"filepath": "a.glb", "yUp": 1},
|
||||||
|
{"filepath": "a.glb", "applyModifiers": None},
|
||||||
|
]
|
||||||
|
for case in cases:
|
||||||
|
self.assertEqual(self.run_action(action="exportGltf", **case)["status"], "failed", case)
|
||||||
|
self.assertEqual(self.bpy.exported, [])
|
||||||
|
self.assertEqual((self.root / "out" / "taken.glb").read_bytes(), b"old")
|
||||||
|
self.assertEqual(sorted(p.name for p in (self.root / "out").iterdir()), ["taken.glb"])
|
||||||
|
self.assertIn("already exists", self.run_action(action="exportGltf", filepath="taken.glb")["message"])
|
||||||
|
|
||||||
|
def test_export_gltf_refuses_an_existing_bin_sibling(self):
|
||||||
|
(self.root / "out" / "a.bin").write_bytes(b"old")
|
||||||
|
self.assertEqual(self.run_action(action="exportGltf", filepath="a.gltf")["status"], "failed")
|
||||||
|
self.assertEqual(self.bpy.exported, [])
|
||||||
|
|
||||||
|
def test_export_refuses_a_symlinked_folder(self):
|
||||||
|
os.symlink(self.root, self.root / "out" / "link")
|
||||||
|
self.assertEqual(self.run_action(action="exportGltf", filepath="link/a.glb")["status"], "failed")
|
||||||
|
self.assertEqual(self.bpy.exported, [])
|
||||||
|
|
||||||
|
def test_export_disabled_without_output_dir(self):
|
||||||
|
self.target.configure_action_dirs(str(self.root / "in"), "")
|
||||||
|
self.assertEqual(self.run_action(action="exportGltf", filepath="a.glb")["status"], "failed")
|
||||||
|
|
||||||
|
def test_export_cancelled_or_no_file_is_failed(self):
|
||||||
|
self.bpy.export_result = {"CANCELLED"}
|
||||||
|
self.assertEqual(self.run_action(action="exportGltf", filepath="a.glb")["status"], "failed")
|
||||||
|
self.bpy.export_result, self.bpy.export_writes = {"FINISHED"}, False
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="b.glb")
|
||||||
|
self.assertEqual((receipt["status"], receipt["message"]), ("failed", "glTF export wrote no file."))
|
||||||
|
|
||||||
|
def test_export_names_the_missing_selection_object(self):
|
||||||
|
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cube", "Nope"])
|
||||||
|
self.assertEqual(receipt["message"], "Selection object was not found: Nope")
|
||||||
|
|
||||||
|
def test_export_is_advertised(self):
|
||||||
|
self.assertIn("asset.export.gltf", bt.CAPABILITIES)
|
||||||
|
|
||||||
# renderStill
|
# renderStill
|
||||||
def test_render_still_writes_inside_output_dir_and_restores_settings(self):
|
def test_render_still_writes_inside_output_dir_and_restores_settings(self):
|
||||||
receipt = self.run_action(action="renderStill", outputPath="shots/a.png", engine="cycles",
|
receipt = self.run_action(action="renderStill", outputPath="shots/a.png", engine="cycles",
|
||||||
|
|
@ -238,6 +379,78 @@ class ActionTests(unittest.TestCase):
|
||||||
self.target.configure_action_dirs(str(self.root / "in"), "")
|
self.target.configure_action_dirs(str(self.root / "in"), "")
|
||||||
self.assertEqual(self.run_action(action="renderStill", outputPath="a.png")["status"], "failed")
|
self.assertEqual(self.run_action(action="renderStill", outputPath="a.png")["status"], "failed")
|
||||||
|
|
||||||
|
# createObject
|
||||||
|
def test_create_object_makes_the_four_admitted_types(self):
|
||||||
|
for kind in ("MESH", "LIGHT", "CAMERA", "EMPTY"):
|
||||||
|
receipt = self.run_action(action="createObject", objectType=kind, name="N")
|
||||||
|
self.assertEqual(receipt["status"], "accepted", kind)
|
||||||
|
self.assertEqual(self.bpy.created[-1][0], kind)
|
||||||
|
self.assertEqual(self.run_action(action="createObject", objectType="MESH", meshType="cube")["status"], "accepted")
|
||||||
|
|
||||||
|
def test_create_object_refuses_other_types_naming_the_type_and_the_allowed_set(self):
|
||||||
|
for kind in ("TEXT", "CURVE", "mesh", "", None, 3):
|
||||||
|
receipt = self.run_action(action="createObject", objectType=kind)
|
||||||
|
self.assertEqual(receipt["status"], "failed", kind)
|
||||||
|
for allowed in ("MESH", "LIGHT", "CAMERA", "EMPTY"):
|
||||||
|
self.assertIn(allowed, receipt["message"])
|
||||||
|
self.assertIn(repr(kind) if not isinstance(kind, str) else kind, receipt["message"])
|
||||||
|
self.assertEqual(self.bpy.created, [])
|
||||||
|
|
||||||
|
def test_create_object_refuses_a_mesh_type_other_than_cube(self):
|
||||||
|
for mesh_type in ("CYLINDER", "", 1, " CUBE", "CUBE "):
|
||||||
|
receipt = self.run_action(action="createObject", objectType="MESH", meshType=mesh_type)
|
||||||
|
self.assertEqual(receipt["status"], "failed", mesh_type)
|
||||||
|
self.assertIn("CUBE", receipt["message"])
|
||||||
|
self.assertEqual(self.bpy.created, [])
|
||||||
|
|
||||||
|
# renderStill: GPU engines without a GL runtime
|
||||||
|
def test_render_still_refuses_gpu_engines_headless_when_the_gl_library_is_missing(self):
|
||||||
|
self.bpy.app.background = True
|
||||||
|
for engine in ("BLENDER_WORKBENCH", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "blender_workbench"):
|
||||||
|
self.bpy.scene.render.ENGINES = FakeRender.ENGINES + ("BLENDER_EEVEE_NEXT",)
|
||||||
|
with mock.patch.object(bt, "missing_gl_library", return_value="libEGL.so.1"):
|
||||||
|
receipt = self.run_action(action="renderStill", outputPath="a.png", engine=engine)
|
||||||
|
self.assertEqual(receipt["status"], "failed", engine)
|
||||||
|
self.assertIn("libEGL.so.1", receipt["message"])
|
||||||
|
self.assertIn("CYCLES", receipt["message"])
|
||||||
|
self.assertEqual(self.bpy.rendered, [])
|
||||||
|
self.assertEqual(self.bpy.scene.render.engine, "BLENDER_EEVEE")
|
||||||
|
|
||||||
|
def test_render_still_refuses_the_scene_engine_too_and_leaves_no_folder(self):
|
||||||
|
self.bpy.app.background = True # scene engine is BLENDER_EEVEE
|
||||||
|
with mock.patch.object(bt, "missing_gl_library", return_value="libEGL.so.1"):
|
||||||
|
receipt = self.run_action(action="renderStill", outputPath="shots/a.png")
|
||||||
|
self.assertEqual(receipt["status"], "failed")
|
||||||
|
self.assertFalse((self.root / "out" / "shots").exists())
|
||||||
|
|
||||||
|
def test_render_still_gl_check_spares_cycles_foreground_and_a_present_library(self):
|
||||||
|
probe = mock.Mock(return_value="libEGL.so.1")
|
||||||
|
with mock.patch.object(bt, "missing_gl_library", probe):
|
||||||
|
self.bpy.app.background = True
|
||||||
|
self.assertEqual(self.run_action(action="renderStill", outputPath="a.png", engine="CYCLES")["status"], "accepted")
|
||||||
|
probe.assert_not_called()
|
||||||
|
self.bpy.app.background = False
|
||||||
|
self.assertEqual(self.run_action(action="renderStill", outputPath="b.png", engine="BLENDER_WORKBENCH")["status"], "accepted")
|
||||||
|
probe.assert_not_called()
|
||||||
|
self.bpy.app.background = True
|
||||||
|
probe.return_value = None
|
||||||
|
self.assertEqual(self.run_action(action="renderStill", outputPath="c.png", engine="BLENDER_WORKBENCH")["status"], "accepted")
|
||||||
|
|
||||||
|
def test_missing_gl_library_probes_only_on_linux_and_only_once(self):
|
||||||
|
bt.missing_gl_library.cache_clear()
|
||||||
|
with mock.patch.object(bt.sys, "platform", "win32"), mock.patch.object(bt.ctypes, "CDLL", side_effect=OSError) as cdll:
|
||||||
|
self.assertIsNone(bt.missing_gl_library())
|
||||||
|
cdll.assert_not_called()
|
||||||
|
bt.missing_gl_library.cache_clear()
|
||||||
|
with mock.patch.object(bt.sys, "platform", "linux"), mock.patch.object(bt.ctypes, "CDLL", side_effect=OSError) as cdll:
|
||||||
|
self.assertEqual(bt.missing_gl_library(), "libEGL.so.1")
|
||||||
|
self.assertEqual(bt.missing_gl_library(), "libEGL.so.1")
|
||||||
|
cdll.assert_called_once_with("libEGL.so.1")
|
||||||
|
bt.missing_gl_library.cache_clear()
|
||||||
|
with mock.patch.object(bt.sys, "platform", "linux"), mock.patch.object(bt.ctypes, "CDLL"):
|
||||||
|
self.assertIsNone(bt.missing_gl_library())
|
||||||
|
bt.missing_gl_library.cache_clear()
|
||||||
|
|
||||||
# importGltf: names
|
# importGltf: names
|
||||||
def test_import_refuses_drive_unc_extended_stream_and_device_names(self):
|
def test_import_refuses_drive_unc_extended_stream_and_device_names(self):
|
||||||
for name in ("C:foo.glb", "C:\\x\\model.glb", "\\\\host\\share\\model.glb", "\\\\?\\C:\\model.glb",
|
for name in ("C:foo.glb", "C:\\x\\model.glb", "\\\\host\\share\\model.glb", "\\\\?\\C:\\model.glb",
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,11 @@ class RealServerTests(unittest.TestCase):
|
||||||
self.assertEqual(server._handle_connection_message(message, {})[0]["schemaVersion"], "cultnet.error.v0")
|
self.assertEqual(server._handle_connection_message(message, {})[0]["schemaVersion"], "cultnet.error.v0")
|
||||||
message[sg.TOKEN_FIELD] = self.target.session_token
|
message[sg.TOKEN_FIELD] = self.target.session_token
|
||||||
response = server._handle_connection_message(message, {})
|
response = server._handle_connection_message(message, {})
|
||||||
self.assertIn("malformed", response[0].get("code", "")) # past the guard, refused by the store as an empty put
|
# Past the guard: the store refuses the empty put and says so with a code. The guard's own
|
||||||
|
# refusal is codeless and names the token field, so either is told apart from the other
|
||||||
|
# without pinning the store's spelling of "an empty put is invalid".
|
||||||
|
self.assertTrue(response[0].get("code"))
|
||||||
|
self.assertNotIn(sg.TOKEN_FIELD, str(response[0]))
|
||||||
|
|
||||||
def test_a_peer_put_is_accepted_over_tcp_because_the_target_grants_peer_writes(self):
|
def test_a_peer_put_is_accepted_over_tcp_because_the_target_grants_peer_writes(self):
|
||||||
status = self.start()
|
status = self.start()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue