procfunc 0.33.1__tar.gz → 0.35.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {procfunc-0.33.1/src/procfunc.egg-info → procfunc-0.35.0}/PKG-INFO +3 -1
  2. {procfunc-0.33.1 → procfunc-0.35.0}/pyproject.toml +3 -1
  3. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/__init__.py +1 -1
  4. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/codegen/codegen.py +77 -14
  5. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/codegen/repr.py +16 -15
  6. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/color.py +8 -11
  7. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/__init__.py +0 -1
  8. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/proxy.py +4 -0
  9. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/util.py +96 -14
  10. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/context.py +0 -40
  11. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/control/__init__.py +2 -5
  12. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/color.py +4 -4
  13. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/construct_standard.py +5 -9
  14. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/execute.py +1 -1
  15. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/realize.py +31 -12
  16. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/util.py +2 -0
  17. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/func.py +21 -17
  18. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/geo.py +10 -55
  19. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/manifest.json +13 -627
  20. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/math.py +72 -4
  21. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/shader.py +0 -39
  22. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/texture.py +99 -97
  23. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/util/bindings_util.py +0 -35
  24. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/util/node_function.py +6 -0
  25. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/attr.py +6 -3
  26. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/primitives/__init__.py +1 -0
  27. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/random.py +12 -0
  28. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/tracer/trace.py +16 -0
  29. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/extract_materials.py +38 -16
  30. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transpiler/bpy_to_computegraph.py +63 -20
  31. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transpiler/main.py +5 -1
  32. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transpiler/parse_special_cases.py +23 -6
  33. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/types.py +22 -7
  34. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/bpy_info.py +18 -16
  35. {procfunc-0.33.1 → procfunc-0.35.0/src/procfunc.egg-info}/PKG-INFO +3 -1
  36. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc.egg-info/requires.txt +2 -0
  37. procfunc-0.35.0/tests/test_codegen.py +89 -0
  38. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_ops.py +28 -0
  39. procfunc-0.35.0/tests/test_random.py +73 -0
  40. procfunc-0.33.1/tests/test_codegen.py +0 -18
  41. procfunc-0.33.1/tests/test_random.py +0 -29
  42. {procfunc-0.33.1 → procfunc-0.35.0}/LICENSE.md +0 -0
  43. {procfunc-0.33.1 → procfunc-0.35.0}/README.md +0 -0
  44. {procfunc-0.33.1 → procfunc-0.35.0}/setup.cfg +0 -0
  45. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/cli/__init__.py +0 -0
  46. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/cli/main.py +0 -0
  47. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/codegen/__init__.py +0 -0
  48. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/codegen/identifiers.py +0 -0
  49. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/compute_graph.py +0 -0
  50. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/node.py +0 -0
  51. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/compute_graph/operators_info.py +0 -0
  52. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/__init__.py +0 -0
  53. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/compositor.py +0 -0
  54. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/construct_nodes.py +0 -0
  55. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/construct_operator.py +0 -0
  56. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/construct_special_cases.py +0 -0
  57. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/execute/infer_runtime_data_type.py +0 -0
  58. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/types.py +0 -0
  59. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/util/__init__.py +0 -0
  60. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/nodes/util/bpy_node_info.py +0 -0
  61. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/__init__.py +0 -0
  62. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/_util.py +0 -0
  63. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/addons.py +0 -0
  64. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/collection.py +0 -0
  65. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/curve.py +0 -0
  66. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/file.py +0 -0
  67. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/manifest.json +0 -0
  68. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/mesh.py +0 -0
  69. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/modifier.py +0 -0
  70. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/object.py +0 -0
  71. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/primitives/camera.py +0 -0
  72. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/primitives/curve.py +0 -0
  73. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/primitives/light.py +0 -0
  74. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/primitives/mesh.py +0 -0
  75. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/ops/uv.py +0 -0
  76. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/tracer/__init__.py +0 -0
  77. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/tracer/decorator.py +0 -0
  78. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/tracer/patch.py +0 -0
  79. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/tracer/proxy.py +0 -0
  80. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/__init__.py +0 -0
  81. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/cleanup.py +0 -0
  82. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/convert.py +0 -0
  83. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/distribution.py +0 -0
  84. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/infer_distribution.py +0 -0
  85. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/parameters.py +0 -0
  86. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transforms/util.py +0 -0
  87. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transpiler/__init__.py +0 -0
  88. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/transpiler/parse_default_values.py +0 -0
  89. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/__init__.py +0 -0
  90. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/bpy_data.py +0 -0
  91. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/camera.py +0 -0
  92. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/keyframe.py +0 -0
  93. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/log.py +0 -0
  94. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/manifest.py +0 -0
  95. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/pytree.py +0 -0
  96. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc/util/teardown.py +0 -0
  97. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc.egg-info/SOURCES.txt +0 -0
  98. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc.egg-info/dependency_links.txt +0 -0
  99. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc.egg-info/entry_points.txt +0 -0
  100. {procfunc-0.33.1 → procfunc-0.35.0}/src/procfunc.egg-info/top_level.txt +0 -0
  101. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_asset.py +0 -0
  102. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_bpy_data_cleanup.py +0 -0
  103. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_cli_transpile.py +0 -0
  104. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_codegen_matrix.py +0 -0
  105. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_compute_graph.py +0 -0
  106. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_node_function.py +0 -0
  107. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_pytree.py +0 -0
  108. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_trace.py +0 -0
  109. {procfunc-0.33.1 → procfunc-0.35.0}/tests/test_transforms.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procfunc
3
- Version: 0.33.1
3
+ Version: 0.35.0
4
4
  Summary: Function-Oriented Abstractions for Procedural 3D Generation in Python
5
5
  License-Expression: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/princeton-vl/procfunc
@@ -16,6 +16,8 @@ Requires-Dist: pytest; extra == "dev"
16
16
  Requires-Dist: pytest-xdist; extra == "dev"
17
17
  Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
18
18
  Requires-Dist: ty; extra == "dev"
19
+ Requires-Dist: imageio; extra == "dev"
20
+ Requires-Dist: matplotlib; extra == "dev"
19
21
  Provides-Extra: docs
20
22
  Requires-Dist: sphinx; extra == "docs"
21
23
  Requires-Dist: sphinx-rtd-theme; extra == "docs"
@@ -27,7 +27,9 @@ dev = [
27
27
  "pytest",
28
28
  "pytest-xdist",
29
29
  "ruff>=0.15,<0.16",
30
- "ty"
30
+ "ty",
31
+ "imageio",
32
+ "matplotlib",
31
33
  ]
32
34
  docs = [
33
35
  "sphinx",
@@ -2,7 +2,7 @@
2
2
  # ensure this gets imported first so that mathutils etc is available even if later modules dont import bpy
3
3
  import bpy
4
4
 
5
- __version__ = "0.33.1"
5
+ __version__ = "0.35.0"
6
6
 
7
7
  from numpy.random import Generator as RNG
8
8
 
@@ -1,3 +1,4 @@
1
+ import ast
1
2
  import dataclasses
2
3
  import enum
3
4
  import inspect
@@ -5,7 +6,7 @@ import itertools
5
6
  import logging
6
7
  from collections import OrderedDict, defaultdict
7
8
  from pathlib import Path
8
- from typing import Any, Callable, Generator
9
+ from typing import Any, Callable, Generator, get_type_hints
9
10
 
10
11
  import numpy as np
11
12
 
@@ -24,6 +25,30 @@ logger = logging.getLogger(__name__)
24
25
 
25
26
  INDENT = " "
26
27
 
28
+ # UnaryOp included since ** binds tighter than unary minus: -2.0 ** x == -(2.0 ** x)
29
+ _PAREN_SENSITIVE_NODES = (ast.BinOp, ast.UnaryOp, ast.Compare, ast.BoolOp, ast.IfExp)
30
+
31
+
32
+ def _has_enclosing_parens(expr: str) -> bool:
33
+ if not (expr.startswith("(") and expr.endswith(")")):
34
+ return False
35
+ depth = 0
36
+ for i, char in enumerate(expr):
37
+ depth += (char == "(") - (char == ")")
38
+ if depth == 0:
39
+ return i == len(expr) - 1
40
+ return False
41
+
42
+
43
+ def _needs_parens(expr: str) -> bool:
44
+ if _has_enclosing_parens(expr):
45
+ return False
46
+ try:
47
+ parsed = ast.parse(expr, mode="eval")
48
+ except SyntaxError:
49
+ return " " in expr
50
+ return isinstance(parsed.body, _PAREN_SENSITIVE_NODES)
51
+
27
52
 
28
53
  def indent_lines(lines: list[str], indent: str = INDENT) -> list[str]:
29
54
  return [indent + line for line in lines]
@@ -52,7 +77,7 @@ def _repr_inp(
52
77
  expr = expr[0]
53
78
  assert isinstance(expr, str)
54
79
 
55
- if " " in expr and extra_parens and expr[0] != "(" and expr[-1] != ")":
80
+ if extra_parens and _needs_parens(expr):
56
81
  return f"({expr})"
57
82
  else:
58
83
  return expr
@@ -146,7 +171,8 @@ def _repr_function_call(
146
171
  if not isinstance(target, cg.Node):
147
172
  raise ValueError(f"Method call {node=} has non-node target {target=}")
148
173
  func = None
149
- func_str = f"{_repr_inp(target, scope_expressions)}.{method_name}"
174
+ target_expr = _repr_inp(target, scope_expressions, extra_parens=True)
175
+ func_str = f"{target_expr}.{method_name}"
150
176
  case cg.SubgraphCallNode(subgraph=subgraph):
151
177
  func = None
152
178
  func_str = scope_expressions.get(id(subgraph))
@@ -176,6 +202,22 @@ def _repr_function_call(
176
202
  return [f"{func_str}({', '.join(arg_reprs)})"]
177
203
 
178
204
 
205
+ # ==/!= only match Blender Compare for exact dtypes; float/vector stay epsilon-tolerant.
206
+ _EPSILON_EQUALITY_TEMPLATES = frozenset(
207
+ OPERATOR_TEMPLATES[op] for op in (OperatorType.EQUAL, OperatorType.NOT_EQUAL)
208
+ )
209
+
210
+
211
+ def _is_exact_compare_operand(value: Any) -> bool:
212
+ if isinstance(value, (cg.Node, cg.Proxy)):
213
+ return False
214
+ if isinstance(value, str):
215
+ return True
216
+ if isinstance(value, bool):
217
+ return True
218
+ return isinstance(value, int) and not isinstance(value, float)
219
+
220
+
179
221
  def _operator_call_operands(
180
222
  node: cg.FunctionCallNode,
181
223
  template: str,
@@ -196,13 +238,21 @@ def _operator_call_operands(
196
238
  if any(name not in bound.arguments for name in operand_names):
197
239
  return None
198
240
 
241
+ operands = [bound.arguments[name] for name in operand_names]
242
+
243
+ # a default epsilon stays tolerant unlike exact ==/!=, so require exact operands
244
+ if template in _EPSILON_EQUALITY_TEMPLATES and not all(
245
+ _is_exact_compare_operand(v) for v in operands
246
+ ):
247
+ return None
248
+
199
249
  for name, value in bound.arguments.items():
200
250
  if name in operand_names:
201
251
  continue
202
252
  if not _kwarg_matches_default(sig, name, value):
203
253
  return None
204
254
 
205
- return [bound.arguments[name] for name in operand_names]
255
+ return operands
206
256
 
207
257
 
208
258
  def _repr_operator_call(
@@ -244,7 +294,7 @@ def _codegen_for_node(
244
294
  else:
245
295
  return _repr_function_call(node, scope_expressions)
246
296
  case cg.MethodCallNode() if node.method_name == "__getitem__":
247
- callee_expr = _repr_inp(node.args[0], scope_expressions)
297
+ callee_expr = _repr_inp(node.args[0], scope_expressions, extra_parens=True)
248
298
  idx_expr = _repr_inp(node.args[1], scope_expressions)
249
299
  return [f"{callee_expr}[{idx_expr}]"]
250
300
  case cg.MethodCallNode():
@@ -280,7 +330,7 @@ def _codegen_graph_inputs(
280
330
  ) -> list[str]:
281
331
  args = sorted(
282
332
  list(graph.inputs.values()),
283
- key=lambda x: x.kwargs.get("default_value", None) is not None,
333
+ key=lambda x: "default_value" in x.kwargs,
284
334
  )
285
335
 
286
336
  func_name = func_name or graph.name
@@ -305,8 +355,8 @@ def _codegen_graph_inputs(
305
355
  else f"{name}"
306
356
  )
307
357
 
308
- if (default := node.kwargs.get("default_value")) is not None:
309
- line += f" = {repr_value(default)}"
358
+ if "default_value" in node.kwargs:
359
+ line += f" = {repr_value(node.kwargs['default_value'])}"
310
360
 
311
361
  args_lines.append(line + ",")
312
362
 
@@ -315,18 +365,31 @@ def _codegen_graph_inputs(
315
365
  return [f"def {func_name}("] + indent_lines(args_lines) + [end_statement]
316
366
 
317
367
 
368
+ def _namedtuple_field_type(node, annotation) -> str:
369
+ vt = node.metadata.get("known_value_type", None)
370
+ if vt is None:
371
+ vt = annotation
372
+ if vt is None:
373
+ return "Any"
374
+ try:
375
+ return repr_type(vt)
376
+ except Exception:
377
+ return "Any"
378
+
379
+
318
380
  def _codegen_namedtuple_def(outputs: pytree.PyTree):
319
381
  tupletype = outputs.toplevel_type()
382
+ try:
383
+ annotations = get_type_hints(tupletype)
384
+ except Exception:
385
+ annotations = getattr(tupletype, "__annotations__", {})
320
386
 
321
387
  type_lines = []
322
388
  for name, node in outputs.items():
323
389
  if node is None:
324
390
  continue
325
- vt = node.metadata.get("known_value_type", None)
326
- if vt is None:
327
- type_lines.append(f"{name}: Any")
328
- else:
329
- type_lines.append(f"{name}: {repr_type(vt)}")
391
+ field_type = _namedtuple_field_type(node, annotations.get(name))
392
+ type_lines.append(f"{name}: {field_type}")
330
393
 
331
394
  return [f"class {tupletype.__name__}(NamedTuple):"] + indent_lines(type_lines)
332
395
 
@@ -840,7 +903,7 @@ def to_python(
840
903
  add_line_comments: bool = False,
841
904
  ) -> str:
842
905
  code_lines = []
843
- code_lines.append("from typing import NamedTuple, Annotated")
906
+ code_lines.append("from typing import Any, NamedTuple, Annotated")
844
907
  code_lines.append("import numpy as np")
845
908
  code_lines.append("import bpy")
846
909
  # code_lines.append("import logging; logging.basicConfig(level=logging.DEBUG)")
@@ -2,6 +2,7 @@ import dataclasses
2
2
  import enum
3
3
  import logging
4
4
  import math
5
+ import types
5
6
  from pathlib import Path
6
7
  from typing import Any, Union, get_args, get_origin
7
8
 
@@ -20,12 +21,22 @@ def repr_type(x: Any) -> str:
20
21
  if isinstance(x, str):
21
22
  return x
22
23
 
23
- if x.__name__ == "NoneType":
24
- return "None"
25
-
26
24
  origin = get_origin(x)
27
25
  args = get_args(x)
28
26
 
27
+ if origin is Union or origin is types.UnionType:
28
+ first_args = get_args(args[0])
29
+ if (
30
+ get_origin(args[0]) is nt.ProcNode
31
+ and first_args
32
+ and first_args[0] is args[1]
33
+ ):
34
+ return f"t.SocketOrVal[{repr_type(first_args[0])}]"
35
+ return " | ".join([repr_type(a) for a in args])
36
+
37
+ if x.__name__ == "NoneType":
38
+ return "None"
39
+
29
40
  if x.__name__ == "ProcNode":
30
41
  if len(args) == 1:
31
42
  return f"pf.ProcNode[{repr_type(args[0])}]"
@@ -42,16 +53,6 @@ def repr_type(x: Any) -> str:
42
53
  if x.__module__ == "builtins":
43
54
  return x.__name__
44
55
 
45
- origin = get_origin(x)
46
- args = get_args(x)
47
-
48
- if origin is Union:
49
- args_0 = get_args(args[0])
50
- if get_origin(args[0]) is nt.ProcNode and args_0[0] is args[1]:
51
- return f"t.SocketOrVal[{repr_type(args_0[0])}]"
52
- else:
53
- return " | ".join([repr_type(a) for a in args])
54
-
55
56
  if getattr(x, "__module__", None) == "procfunc.nodes.types":
56
57
  return f"t.{x.__name__}"
57
58
 
@@ -70,12 +71,12 @@ def repr_value(value: Any) -> str:
70
71
  value = value.__wrapped__
71
72
 
72
73
  if isinstance(value, cg.Proxy):
73
- logger.warning(
74
+ raise ValueError(
74
75
  f"Proxy object {value} should never appear as a raw value in codegen - "
75
76
  f"its underlying node {value.node} was not resolved to a variable"
76
77
  )
77
78
  if isinstance(value, nt.ProcNode):
78
- logger.warning(
79
+ raise ValueError(
79
80
  f"Procnode object {value} should never be treated as a raw value in codegen"
80
81
  )
81
82
 
@@ -10,6 +10,8 @@ def hsv_color(
10
10
  saturation: float | None = None,
11
11
  value: float | None = None,
12
12
  ) -> t.Color:
13
+ if hsv is None and (hue is None or saturation is None or value is None):
14
+ raise ValueError("provide hsv=... or all of hue/saturation/value")
13
15
  color = t.Color()
14
16
  if hsv is not None:
15
17
  color.hsv = hsv
@@ -27,12 +29,9 @@ def rgb_color(
27
29
  b: float | None = None,
28
30
  rgb: np.ndarray | None = None,
29
31
  ) -> t.Color:
30
- color = t.Color()
31
- if rgb is not None:
32
- color.r, color.g, color.b = rgb
33
- else:
34
- color.r, color.g, color.b = r, g, b
35
- return color
32
+ if rgb is None and (r is None or g is None or b is None):
33
+ raise ValueError("provide rgb=... or all of r/g/b")
34
+ return t.Color(rgb if rgb is not None else (r, g, b))
36
35
 
37
36
 
38
37
  def _srgb_to_linearrgb(c):
@@ -44,14 +43,12 @@ def _srgb_to_linearrgb(c):
44
43
  return ((c + 0.055) / 1.055) ** 2.4
45
44
 
46
45
 
47
- def _hex_to_rgb(h: int, alpha: float = 1):
46
+ def _hex_to_rgb(h: int):
48
47
  r = (h & 0xFF0000) >> 16
49
48
  g = (h & 0x00FF00) >> 8
50
49
  b = h & 0x0000FF
51
50
  return tuple([_srgb_to_linearrgb(c / 0xFF) for c in (r, g, b)])
52
51
 
53
52
 
54
- def hex_color(h: int, alpha: float = 1):
55
- c = t.Color()
56
- c.r, c.g, c.b = _hex_to_rgb(h, alpha)
57
- return c
53
+ def hex_color(h: int) -> t.Color:
54
+ return t.Color(_hex_to_rgb(h))
@@ -45,7 +45,6 @@ __all__ = [
45
45
  "LiteralConstant",
46
46
  "graph_nodes_equal",
47
47
  "transform_compute_graph",
48
- "transform_nodetree",
49
48
  "traverse_breadth_first",
50
49
  "traverse_depth_first",
51
50
  "traverse_depth_first_node",
@@ -15,6 +15,10 @@ class Proxy(Generic[T]):
15
15
 
16
16
  node: Node
17
17
 
18
+ # Tell numpy to defer (ndarray + Proxy -> Proxy.__radd__) instead of probing the
19
+ # Proxy as an array, which would fabricate a bogus __array_struct__ via __getattr__.
20
+ __array_ufunc__ = None
21
+
18
22
  def __repr__(self):
19
23
  return f"Proxy({self.node!r})"
20
24
 
@@ -3,10 +3,21 @@ import logging
3
3
  from collections import defaultdict, deque
4
4
  from typing import Any, Callable, Generator, Literal, TypeVar
5
5
 
6
+ import numpy as np
7
+
6
8
  from procfunc.util import pytree
7
9
 
8
10
  from .compute_graph import ComputeGraph
9
- from .node import Node, SubgraphCallNode
11
+ from .node import (
12
+ ConstantNode,
13
+ FunctionCallNode,
14
+ GetAttributeNode,
15
+ InputPlaceholderNode,
16
+ MethodCallNode,
17
+ Node,
18
+ ProceduralNode,
19
+ SubgraphCallNode,
20
+ )
10
21
 
11
22
  logger = logging.getLogger(__name__)
12
23
 
@@ -40,6 +51,7 @@ def traverse_breadth_first(
40
51
 
41
52
  visited = set()
42
53
  frontier = deque((None, name, node) for name, node in graph.outputs.items())
54
+ visited.update(id(node) for _, _, node in frontier)
43
55
  # logger.debug(f"{traverse_breadth_first.__name__} {graph.name} {len(frontier)=}")
44
56
 
45
57
  def res(parent, name, child):
@@ -61,10 +73,6 @@ def traverse_breadth_first(
61
73
  if not isinstance(node, Node):
62
74
  continue
63
75
 
64
- if id(node) in visited:
65
- continue
66
- visited.add(id(node))
67
-
68
76
  yield res(parent, name, node)
69
77
 
70
78
  children = list(pytree.PyTree(node.args).items()) + list(
@@ -75,6 +83,7 @@ def traverse_breadth_first(
75
83
  continue
76
84
  if id(arg) in visited:
77
85
  continue
86
+ visited.add(id(arg))
78
87
  frontier.append((node, key, arg))
79
88
 
80
89
 
@@ -201,22 +210,95 @@ def usages_per_node(
201
210
  return dict(usages)
202
211
 
203
212
 
204
- def graph_nodes_equal(graph1: ComputeGraph, graph2: ComputeGraph) -> bool:
205
- nodes1 = list(traverse_depth_first(graph1))
206
- nodes2 = list(traverse_depth_first(graph2))
207
- if len(nodes1) != len(nodes2):
213
+ def _value_equal(a: Any, b: Any) -> bool:
214
+ """Array-safe equality for non-node values (constants, attrs, defaults)."""
215
+ if a is b:
216
+ return True
217
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
218
+ return np.array_equal(a, b)
219
+ if isinstance(a, dict) and isinstance(b, dict):
220
+ return a.keys() == b.keys() and all(_value_equal(a[k], b[k]) for k in a)
221
+ if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
222
+ return (
223
+ type(a) is type(b)
224
+ and len(a) == len(b)
225
+ and all(_value_equal(x, y) for x, y in zip(a, b))
226
+ )
227
+ try:
228
+ return bool(a == b)
229
+ except (ValueError, TypeError):
230
+ return False
231
+
232
+
233
+ def _args_equal(a: Any, b: Any, memo: dict[tuple[int, int], bool]) -> bool:
234
+ """Structural equality of an args/kwargs tree: same container shape, with
235
+ node-valued leaves compared recursively and other leaves by value."""
236
+ tree_a = pytree.PyTree(a)
237
+ tree_b = pytree.PyTree(b)
238
+ if tree_a.spec != tree_b.spec:
208
239
  return False
209
- for node1, node2 in zip(nodes1, nodes2):
210
- if type(node1) is not type(node2):
240
+ for leaf_a, leaf_b in zip(tree_a.values(), tree_b.values()):
241
+ a_is_node = isinstance(leaf_a, Node)
242
+ b_is_node = isinstance(leaf_b, Node)
243
+ if a_is_node != b_is_node:
211
244
  return False
212
- if isinstance(node1, SubgraphCallNode):
213
- if not graph_nodes_equal(node1.subgraph, node2.subgraph):
245
+ if a_is_node:
246
+ if not _nodes_equal(leaf_a, leaf_b, memo):
214
247
  return False
215
- elif node1.args != node2.args or node1.kwargs != node2.kwargs:
248
+ elif not _value_equal(leaf_a, leaf_b):
216
249
  return False
217
250
  return True
218
251
 
219
252
 
253
+ def _nodes_equal(node1: Node, node2: Node, memo: dict[tuple[int, int], bool]) -> bool:
254
+ if node1 is node2:
255
+ return True
256
+ if type(node1) is not type(node2):
257
+ return False
258
+
259
+ key = (id(node1), id(node2))
260
+ cached = memo.get(key)
261
+ if cached is not None:
262
+ return cached
263
+ memo[key] = True # optimistic, breaks cycles in shared DAGs
264
+
265
+ result = True
266
+ if isinstance(node1, SubgraphCallNode):
267
+ result = graph_nodes_equal(node1.subgraph, node2.subgraph)
268
+ if result and isinstance(node1, FunctionCallNode):
269
+ result = node1.func is node2.func
270
+ if result and isinstance(node1, MethodCallNode):
271
+ result = node1.method_name == node2.method_name
272
+ if result and isinstance(node1, GetAttributeNode):
273
+ result = node1.attribute_name == node2.attribute_name
274
+ if result and isinstance(node1, ProceduralNode):
275
+ result = node1.node_type == node2.node_type and _value_equal(
276
+ node1.attrs, node2.attrs
277
+ )
278
+ if result and isinstance(node1, ConstantNode):
279
+ result = _value_equal(node1.value, node2.value)
280
+ if result and isinstance(node1, InputPlaceholderNode):
281
+ result = node1.input_name == node2.input_name and _value_equal(
282
+ node1.default_value, node2.default_value
283
+ )
284
+ if result:
285
+ result = _args_equal(node1.args, node2.args, memo) and _args_equal(
286
+ node1.kwargs, node2.kwargs, memo
287
+ )
288
+
289
+ memo[key] = result
290
+ return result
291
+
292
+
293
+ def graph_nodes_equal(graph1: ComputeGraph, graph2: ComputeGraph) -> bool:
294
+ nodes1 = list(traverse_depth_first(graph1))
295
+ nodes2 = list(traverse_depth_first(graph2))
296
+ if len(nodes1) != len(nodes2):
297
+ return False
298
+ memo: dict[tuple[int, int], bool] = {}
299
+ return all(_nodes_equal(node1, node2, memo) for node1, node2 in zip(nodes1, nodes2))
300
+
301
+
220
302
  def transform_nodetree(
221
303
  root: Node,
222
304
  transform_fn: Callable[[Node], Any],
@@ -13,24 +13,6 @@ class ProcfuncContext:
13
13
  num_cpu_cores: int
14
14
  current_trace_level: int | None # compared against to TraceLevel int values
15
15
 
16
- warn_mode_avoid_normal_bump: Literal["ignore", "warn", "throw"]
17
- """
18
- Set to 'throw' for strict usage. Using a normal map / bump map / BSDF vector input _can_ be useful,
19
- but its always better to do it via the Displacement output of the shader (so that it at least can also be done as displacement)
20
- """
21
-
22
- warn_mode_avoid_implicit_vector: Literal["ignore", "warn", "throw"]
23
- """
24
- Set to 'throw' to force the user to specify exactly what vector to sample.
25
- This prevents materials from being incorrect when on a moving object, or ignoring uv coordinates
26
- """
27
-
28
- warn_mode_avoid_io_nodes: Literal["ignore", "warn", "throw"]
29
- """
30
- Set to 'throw' to prevent floating Value Vector Color nodes, or strange output nodes like AOV in shader
31
- Intent is to force nodegroups to be more functional - all inputs and outputs go through nodegroup interface
32
- """
33
-
34
16
  warn_mode_empty_geonodes: Literal["ignore", "warn", "throw"]
35
17
  """
36
18
  Controls behavior when a geometry node graph produces no mesh geometry (e.g. unconnected inputs).
@@ -44,16 +26,10 @@ class ProcfuncContext:
44
26
 
45
27
  def set_strict(self):
46
28
  """Set all warning modes to 'throw'"""
47
- self.warn_mode_avoid_normal_bump = "throw"
48
- self.warn_mode_avoid_implicit_vector = "throw"
49
- self.warn_mode_avoid_io_nodes = "throw"
50
29
  self.warn_mode_empty_geonodes = "throw"
51
30
 
52
31
  def set_warn(self):
53
32
  """Set all warning modes to 'warn'"""
54
- self.warn_mode_avoid_normal_bump = "warn"
55
- self.warn_mode_avoid_implicit_vector = "warn"
56
- self.warn_mode_avoid_io_nodes = "warn"
57
33
  self.warn_mode_empty_geonodes = "warn"
58
34
 
59
35
 
@@ -61,27 +37,11 @@ class ProcfuncContext:
61
37
 
62
38
  warn_modes = ["ignore", "warn", "throw"]
63
39
 
64
- _warn_mode_avoid_normal_bump = os.environ.get(
65
- "PROCFUNC_WARN_MODE_AVOID_NORMAL_BUMP", "ignore"
66
- )
67
- assert _warn_mode_avoid_normal_bump in warn_modes
68
-
69
- warn_mode_avoid_implicit_vector = os.environ.get(
70
- "PROCFUNC_WARN_MODE_AVOID_IMPLICIT_VECTOR", "ignore"
71
- )
72
- assert warn_mode_avoid_implicit_vector in warn_modes
73
-
74
- warn_mode_avoid_io_nodes = os.environ.get("PROCFUNC_WARN_MODE_AVOID_IO_NODES", "ignore")
75
- assert warn_mode_avoid_io_nodes in warn_modes
76
-
77
40
  _warn_mode_empty_geonodes = os.environ.get("PROCFUNC_WARN_MODE_EMPTY_GEONODES", "warn")
78
41
  assert _warn_mode_empty_geonodes in warn_modes
79
42
 
80
43
  globals = ProcfuncContext(
81
44
  num_cpu_cores=int(os.environ.get("PROCFUNC_NUM_CPU_CORES", 0)),
82
- warn_mode_avoid_normal_bump=_warn_mode_avoid_normal_bump, # type: ignore[invalid-assignment]
83
- warn_mode_avoid_implicit_vector=warn_mode_avoid_implicit_vector, # type: ignore[invalid-assignment]
84
- warn_mode_avoid_io_nodes=warn_mode_avoid_io_nodes, # type: ignore[invalid-assignment]
85
45
  warn_mode_empty_geonodes=_warn_mode_empty_geonodes, # type: ignore[invalid-assignment]
86
46
  current_trace_level=None,
87
47
  )
@@ -127,12 +127,9 @@ def choice(
127
127
  ) -> T:
128
128
  """
129
129
  Args:
130
- rng: random number generator
131
- weights: list of weights for each option - will be normalized to sum to 1 as a probability distribution
132
- options: list of callables to choose from
133
- chosen: if not None, use this value instead of choosing randomly. Cannot be traced, but may appear in the output of the tracer.
130
+ choice_rng: random number generator
131
+ choice_options: list of (value, weight) tuples; weights are normalized to a probability distribution
134
132
  chosen_idx: if not None, use this index instead of choosing randomly. Cannot be traced, but may appear in the output of the tracer.
135
- **child_kwargs: keyword arguments to pass to the chosen callable
136
133
 
137
134
  TODO: this function should really take args and kwargs as tuple & dict, not via expansion
138
135
  but this requires our Node to handle cases with pytrees as inputs. Currently it would fail to execute the children if they are hidden in a pytree
@@ -39,7 +39,7 @@ def mix_rgb(
39
39
  clamp_factor: bool = True,
40
40
  ) -> nt.ProcNode[pt.Color]:
41
41
  """
42
- Uses a Mix Node with datatype Color
42
+ Uses a Mix Shader Node with data_type='RGBA'.
43
43
 
44
44
  NOTE: separated from float/vector mix() due to extra arguments
45
45
 
@@ -80,7 +80,7 @@ def combine_rgb(
80
80
  blue: nt.SocketOrVal[float] = 0.0,
81
81
  ) -> nt.ProcNode[pt.Color]:
82
82
  """
83
- Uses a CombineColor Shader Node.
83
+ Uses a CombineColor Shader Node in RGB mode.
84
84
 
85
85
  See: https://docs.blender.org/manual/en/4.2/render/shader_nodes/converter/combine_color.html
86
86
  """
@@ -97,7 +97,7 @@ def combine_hsv(
97
97
  value: nt.SocketOrVal[float] = 0.0,
98
98
  ) -> nt.ProcNode[pt.Color]:
99
99
  """
100
- Uses a CombineColor Shader Node.
100
+ Uses a CombineColor Shader Node in HSV mode.
101
101
 
102
102
  See: https://docs.blender.org/manual/en/4.2/render/shader_nodes/converter/combine_color.html
103
103
  """
@@ -115,7 +115,7 @@ def combine_hsl(
115
115
  lightness: nt.SocketOrVal[float] = 0.0,
116
116
  ) -> nt.ProcNode[pt.Color]:
117
117
  """
118
- Uses a CombineHSV Shader Node.
118
+ Uses a CombineColor Shader Node in HSL mode.
119
119
 
120
120
  See: https://docs.blender.org/manual/en/4.2/render/shader_nodes/converter/combine_color.html
121
121
  """
@@ -320,26 +320,22 @@ def _construct_procnode_standard(
320
320
  for input_name, input_py in kwargs.items():
321
321
  input_result = input_results[input_name]
322
322
  if input_result is None:
323
- # Strict-None policy: None means "leave disconnected" and is only
324
- # allowed for sockets with no default_value attr at all (Geometry,
325
- # Shader, Matrix, Virtual) - never rely on Blender's internal
326
- # defaults, whose values may change across versions. A disabled or
327
- # missing socket name is a binding bug and propagates as ValueError.
328
323
  to_socket = get_input_socket_to_connect_to(
329
324
  bl_node_tree, bl_node, input_name, None
330
325
  )
331
- # IMAGE default_value is a datablock pointer; None faithfully means "no image assigned"
332
326
  if (
333
327
  to_socket.is_multi_input
334
328
  or not hasattr(to_socket, "default_value")
335
- or to_socket.type == "IMAGE"
329
+ or to_socket.hide_value
330
+ or to_socket.default_value is None
336
331
  ):
337
332
  continue
338
333
  raise ValueError(
339
334
  f"Node {bl_node.name!r} input {to_socket.name!r} (socket type "
340
335
  f"{to_socket.type}) received None. Explicitly pass a value; None is "
341
- f"only allowed for sockets with no default_value attribute "
342
- f"(e.g. Geometry/Shader) or with a datablock pointer one (Image)."
336
+ f"only allowed for disconnected implicit-field sockets (hide_value), "
337
+ f"datablock pointers, or sockets with no default_value attribute "
338
+ f"(e.g. Geometry/Shader)."
343
339
  )
344
340
  to_socket = get_input_socket_to_connect_to(
345
341
  bl_node_tree, bl_node, input_name, input_result
@@ -67,7 +67,7 @@ def to_environment(
67
67
  return pt.World(world)
68
68
 
69
69
 
70
- @tracer_primitive
70
+ @tracer_primitive(mutates=["light"])
71
71
  def to_light(
72
72
  light: pt.LightObject,
73
73
  surface: nt.ProcNode[nt.Shader],