@volter/blender-engine 0.1.6 → 0.1.8

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.
@@ -526,6 +526,475 @@ def draw_camera(obj):
526
526
  }
527
527
 
528
528
 
529
+ # ------------------------------------------------------- the material graphs
530
+
531
+ # THE MATERIAL GRAPH, for a surface whose inputs are more than constants.
532
+ #
533
+ # The export door reduces each material to what a three.js standard material
534
+ # holds -- a constant or one image per input. A LINKED input of a Principled
535
+ # BSDF or an Emission is instead shipped as the node graph that drives it, and
536
+ # the presenter compiles that graph with Blender's own node GLSL
537
+ # (`three/blender-node-graph.ts`, `three/blender-node-glsl.generated.ts`). The
538
+ # door is told which materials those are (`graph_materials`), so it stays quiet
539
+ # about reducing them, which images their graphs sample (`graph_images`), so it
540
+ # ships those pictures with the same revisions as every other, and which read
541
+ # Generated coordinates (`graph_generated`), so a deformed mesh wearing one
542
+ # ships Blender's orco -- or the door names why it cannot.
543
+ #
544
+ # The graph is flattened here: reroutes and muted nodes are followed the way
545
+ # Blender's own node tree evaluation follows them, and group nodes are inlined
546
+ # (their nodes keyed by the group path), so the presenter sees one acyclic
547
+ # list of Blender nodes. What reaches the presenter is Blender's own data:
548
+ # every input socket in declaration order with its GPU type and value, every
549
+ # output socket's GPU type, and the node's own RNA properties.
550
+
551
+ # The node types the presenter compiles. A graph reaching any other type is
552
+ # not shipped; the door's reduction stands and a warning names the node.
553
+
554
+ # EVERY LINK INTO A SOCKET, from one pass over its tree. `NodeSocket.links`
555
+ # is Python in Blender (`_bpy_types.py`): each read walks ALL of the tree's
556
+ # links, and the graph pass reads it per socket per present -- measured on a
557
+ # 28-material scene, 875 reads and 9 of the pass's 15 profiled seconds. The
558
+ # map is built once per tree per present (`material_graphs` clears it).
559
+ _TREE_LINKS = {}
560
+
561
+
562
+ def _links_into(socket):
563
+ """The valid, unmuted links into `socket` (Blender's own `links`, filtered)."""
564
+ tree = socket.id_data
565
+ held = _TREE_LINKS.get(tree.as_pointer())
566
+ if held is None:
567
+ held = {}
568
+ for link in tree.links:
569
+ if link.is_valid and not link.is_muted:
570
+ held.setdefault(link.to_socket.as_pointer(), []).append(link)
571
+ _TREE_LINKS[tree.as_pointer()] = held
572
+ return held.get(socket.as_pointer(), [])
573
+
574
+
575
+ _GRAPH_NODES = frozenset((
576
+ "ShaderNodeTexCoord", "ShaderNodeUVMap", "ShaderNodeValue", "ShaderNodeRGB",
577
+ "ShaderNodeTexImage", "ShaderNodeMapping", "ShaderNodeMath", "ShaderNodeVectorMath",
578
+ "ShaderNodeMix", "ShaderNodeMixRGB", "ShaderNodeValToRGB", "ShaderNodeInvert",
579
+ "ShaderNodeSeparateXYZ", "ShaderNodeCombineXYZ", "ShaderNodeTexNoise",
580
+ "ShaderNodeTexVoronoi", "ShaderNodeTexChecker", "ShaderNodeTexWhiteNoise",
581
+ "ShaderNodeTexWave", "ShaderNodeTexGradient", "ShaderNodeTexMagic", "ShaderNodeTexBrick",
582
+ "ShaderNodeMapRange", "ShaderNodeClamp", "ShaderNodeHueSaturation", "ShaderNodeBrightContrast",
583
+ "ShaderNodeGamma", "ShaderNodeRGBToBW", "ShaderNodeSeparateColor", "ShaderNodeCombineColor",
584
+ "ShaderNodeVectorRotate", "ShaderNodeFresnel", "ShaderNodeLayerWeight", "ShaderNodeBump",
585
+ "ShaderNodeNormalMap", "ShaderNodeNewGeometry", "ShaderNodeVertexColor", "ShaderNodeAttribute",
586
+ "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve",
587
+ ))
588
+ # The surfaces whose inputs map onto the presenter's standard material, and
589
+ # the inputs of each the presenter reads from a graph.
590
+ _GRAPH_SURFACES = {
591
+ "ShaderNodeBsdfPrincipled": ("Base Color", "Metallic", "Roughness", "Alpha",
592
+ "Emission Color", "Emission Strength", "Normal"),
593
+ "ShaderNodeEmission": ("Color", "Strength"),
594
+ }
595
+ # Procedural textures whose unlinked Vector is Generated coordinates.
596
+ _GENERATED_BY_DEFAULT = frozenset((
597
+ "ShaderNodeTexNoise", "ShaderNodeTexVoronoi", "ShaderNodeTexChecker", "ShaderNodeTexWave",
598
+ "ShaderNodeTexGradient", "ShaderNodeTexMagic", "ShaderNodeTexBrick",
599
+ ))
600
+ # RNA properties every node has, which describe the node's place in the editor
601
+ # rather than what it computes.
602
+ _NODE_BASE_PROPERTIES = frozenset(p.identifier for p in bpy.types.ShaderNode.bl_rna.properties)
603
+
604
+
605
+ def _gpu_type(socket):
606
+ """Blender's `node_gpu_stack_from_data`: the GLSL type a socket is passed
607
+ as, or None for a socket GPU codegen leaves out of the call."""
608
+ kind = socket.type
609
+ if kind in ("VALUE", "INT", "BOOLEAN"):
610
+ return "float"
611
+ if kind == "VECTOR":
612
+ return "vec%d" % len(socket.default_value) if hasattr(socket, "default_value") else "vec3"
613
+ if kind in ("RGBA", "ROTATION"):
614
+ return "vec4"
615
+ if kind == "SHADER":
616
+ return "closure"
617
+ return None
618
+
619
+
620
+ def _socket_value(socket, gpu):
621
+ """An unlinked socket's value in its GPU type (`nodestack_get_vec`)."""
622
+ if not hasattr(socket, "default_value"):
623
+ return 0.0 if gpu == "float" else [0.0] * int(gpu[3:])
624
+ value = socket.default_value
625
+ if gpu == "float":
626
+ return float(value)
627
+ values = [float(v) for v in value]
628
+ if socket.type == "ROTATION":
629
+ # A rotation socket's value is an Euler; codegen carries it as a vec4.
630
+ values = values + [0.0]
631
+ return values
632
+
633
+
634
+ def _curve_mapping(mapping):
635
+ """What `gpu_shader_curve_*` hands the GPU, read through Blender itself:
636
+ `BKE_curvemapping_table_RGBA`'s 257 samples per curve, each curve's table
637
+ range (`mintable`/`maxtable`: the clip rectangle widened to the points,
638
+ `curvemap_make_table`) and its extrapolation slopes in that normalized
639
+ range (`BKE_curvemapping_compute_slopes`; horizontal without Extrapolated).
640
+ The samples and slopes come from `CurveMapping.evaluate`, the same
641
+ evaluation the table holds -- never a transcribed Bezier."""
642
+ curves = list(mapping.curves)
643
+ table = [[0.0, 0.0, 0.0, 0.0] for _ in range(_RAMP_SAMPLES)]
644
+ minimums, dividers, starts, ends = [0.0] * 4, [1e8] * 4, [0.0] * 4, [0.0] * 4
645
+ extrapolate = mapping.extend == "EXTRAPOLATED"
646
+ for index, curve in enumerate(curves[:4]):
647
+ points = [p.location for p in curve.points]
648
+ if len(points) >= 2:
649
+ low = min([mapping.clip_min_x] + [p[0] for p in points])
650
+ high = max([mapping.clip_max_x] + [p[0] for p in points])
651
+ else:
652
+ low = high = 0.0
653
+ span = high - low
654
+ minimums[index] = low
655
+ dividers[index] = 1.0 / max(1e-8, span)
656
+ for i in range(_RAMP_SAMPLES):
657
+ table[i][index] = float(mapping.evaluate(curve, low + span * i / (_RAMP_SAMPLES - 1)))
658
+ if extrapolate and span > 0:
659
+ step = span * 1e-3
660
+ starts[index] = (mapping.evaluate(curve, low) - mapping.evaluate(curve, low - step)) / step * span
661
+ ends[index] = (mapping.evaluate(curve, high + step) - mapping.evaluate(curve, high)) / step * span
662
+ return {"table": table, "minimums": minimums, "dividers": dividers, "starts": starts, "ends": ends}
663
+
664
+
665
+ def _node_properties(node):
666
+ """The node's own RNA properties: enums, flags and numbers, plus the two
667
+ data-block values a compiled node reads (its colour ramp, its image)."""
668
+ props = {}
669
+ for prop in node.bl_rna.properties:
670
+ name = prop.identifier
671
+ if name in _NODE_BASE_PROPERTIES:
672
+ continue
673
+ if prop.type in ("ENUM", "BOOLEAN", "INT", "FLOAT", "STRING") and not getattr(prop, "is_array", False):
674
+ value = getattr(node, name)
675
+ props[name] = sorted(value) if isinstance(value, set) else value
676
+ if prop.type == "ENUM" and not prop.is_enum_flag:
677
+ # The enum's DNA value, which is what the GPU binding passes as
678
+ # a constant (`GPU_constant(&tex->wave_type)`): read from RNA,
679
+ # never transcribed.
680
+ try:
681
+ props[name + "#value"] = prop.enum_items[value].value
682
+ except (KeyError, TypeError):
683
+ pass # a dynamic enum has no static item table
684
+ if node.bl_idname == "ShaderNodeValToRGB":
685
+ ramp = node.color_ramp
686
+ props["color_ramp"] = {
687
+ "interpolation": ramp.interpolation,
688
+ "color_mode": ramp.color_mode,
689
+ "elements": [[float(e.position)] + [float(c) for c in e.color] for e in ramp.elements],
690
+ # The table Blender's GPU path samples (BKE_colorband_evaluate_table_rgba),
691
+ # read through Blender's own evaluator.
692
+ "table": [[float(c) for c in ramp.evaluate(i / 256)] for i in range(_RAMP_SAMPLES)],
693
+ }
694
+ if node.bl_idname in ("ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve"):
695
+ props["curve_mapping"] = _curve_mapping(node.mapping)
696
+ if node.bl_idname == "ShaderNodeTexImage":
697
+ image = node.image
698
+ props["image"] = image.name if image is not None else None
699
+ if image is not None:
700
+ # What decides the Color output's alpha handling
701
+ # (`node_shader_gpu_tex_image`): the image's alpha mode, and
702
+ # whether its colour space is data.
703
+ props["image_alpha_mode"] = image.alpha_mode
704
+ props["image_is_data"] = bool(image.colorspace_settings.is_data)
705
+ # UDIM: a flat projection samples every tile (the door ships them).
706
+ props["image_tiled"] = image.source == "TILED"
707
+ if node.bl_idname in ("ShaderNodeValue", "ShaderNodeRGB"):
708
+ # These nodes' value is their OUTPUT socket's (`set_value`/`set_rgba`
709
+ # of a uniform in Blender's GPU path).
710
+ props["value"] = _socket_value(node.outputs[0], _gpu_type(node.outputs[0]))
711
+ return props
712
+
713
+
714
+ def _refusal(node):
715
+ """What of a supported node type the presenter does not compile yet, or None."""
716
+ kind = node.bl_idname
717
+ if kind == "ShaderNodeTexImage":
718
+ image = node.image
719
+ if node.projection == "BOX" and node.extension == "CLIP":
720
+ return "%s clips a Box projection, which is not compiled" % node.name
721
+ if image is not None and image.source not in ("FILE", "GENERATED", "TILED"):
722
+ return "%s's image is a %s source" % (node.name, image.source)
723
+ if kind == "ShaderNodeNewGeometry" and node.outputs["Parametric"].is_linked:
724
+ # EEVEE's parametric is the triangle's barycentrics, which the
725
+ # presenter's meshes do not carry.
726
+ return "%s's Parametric output is not compiled" % node.name
727
+ if kind == "ShaderNodeAttribute" and node.attribute_type != "GEOMETRY":
728
+ return "%s reads an %s attribute; only Geometry attributes are compiled" % (
729
+ node.name, node.attribute_type.replace("_", " ").title())
730
+ if kind == "ShaderNodeTexCoord" and node.object is not None:
731
+ return "%s reads object coordinates of %s" % (node.name, node.object.name)
732
+ return None
733
+
734
+
735
+ class _GraphRefusal(Exception):
736
+ pass
737
+
738
+
739
+ class _MaterialGraph:
740
+ """One material's graph, flattened. `nodes` is keyed by group path."""
741
+
742
+ def __init__(self, material):
743
+ self.material = material
744
+ self.nodes = {}
745
+ self.images = set()
746
+ self._building = set()
747
+ # Whether the graph reads Generated coordinates: Texture Coordinate's
748
+ # first output, or a procedural texture's unlinked Vector, which
749
+ # Blender defaults to Generated (`node_shader_gpu_default_tex_coord`).
750
+ self.generated = False
751
+
752
+ # A socket is addressed inside a STACK of group nodes; the empty stack is
753
+ # the material's own tree.
754
+
755
+ def source(self, stack, socket):
756
+ """An input socket's value: `{"value": v}` or `{"link": [node, output]}`."""
757
+ gpu = _gpu_type(socket)
758
+ links = _links_into(socket)
759
+ if not links:
760
+ return {"value": _socket_value(socket, gpu)}
761
+ return self.output(stack, links[0].from_node, links[0].from_socket, gpu)
762
+
763
+ def output(self, stack, node, socket, wanted):
764
+ kind = node.bl_idname
765
+ if kind == "NodeReroute":
766
+ return self.source(stack, node.inputs[0])
767
+ if node.mute:
768
+ for link in node.internal_links:
769
+ if link.to_socket == socket:
770
+ return self.source(stack, link.from_socket)
771
+ return {"value": 0.0 if wanted == "float" else [0.0] * int(wanted[3:])}
772
+ if kind == "ShaderNodeGroup":
773
+ tree = node.node_tree
774
+ if tree is None:
775
+ raise _GraphRefusal("group %s has no node tree" % node.name)
776
+ outputs = [n for n in tree.nodes if n.bl_idname == "NodeGroupOutput"]
777
+ active = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
778
+ if active is None:
779
+ raise _GraphRefusal("group %s has no Group Output" % tree.name)
780
+ inner = next(s for s in active.inputs if s.identifier == socket.identifier)
781
+ return self.source(stack + [node], inner)
782
+ if kind == "NodeGroupInput":
783
+ if not stack:
784
+ raise _GraphRefusal("a Group Input outside a group")
785
+ outer = stack[-1]
786
+ outer_socket = next(s for s in outer.inputs if s.identifier == socket.identifier)
787
+ return self.source(stack[:-1], outer_socket)
788
+ if kind not in _GRAPH_NODES:
789
+ raise _GraphRefusal("%s (%s) is not compiled by the presenter" % (node.name, kind))
790
+ refusal = _refusal(node)
791
+ if refusal is not None:
792
+ raise _GraphRefusal(refusal)
793
+ key = "/".join([group.name for group in stack] + [node.name])
794
+ if key not in self.nodes:
795
+ if key in self._building:
796
+ raise _GraphRefusal("%s is part of a cycle" % node.name)
797
+ self._building.add(key)
798
+ self.nodes[key] = self._describe(stack, node)
799
+ self._building.discard(key)
800
+ index = next(i for i, s in enumerate(node.outputs) if s == socket)
801
+ # Generated coordinates, and the Geometry node's Tangent (node_tangent
802
+ # of the orco).
803
+ if (kind == "ShaderNodeTexCoord" and index == 0) or (
804
+ kind == "ShaderNodeNewGeometry" and socket.name == "Tangent"):
805
+ self.generated = True
806
+ return {"link": [key, index]}
807
+
808
+ def _describe(self, stack, node):
809
+ if node.bl_idname in _GENERATED_BY_DEFAULT and not bool(_links_into(node.inputs[0])):
810
+ self.generated = True
811
+ props = _node_properties(node)
812
+ if props.get("image"):
813
+ self.images.add(props["image"])
814
+ return {
815
+ "type": node.bl_idname,
816
+ "props": props,
817
+ "inputs": [
818
+ dict(self.source(stack, s), id=s.identifier, gpu=_gpu_type(s), enabled=s.enabled)
819
+ for s in node.inputs if _gpu_type(s) not in (None, "closure")
820
+ ],
821
+ "outputs": [{"id": s.identifier, "gpu": _gpu_type(s)} for s in node.outputs],
822
+ }
823
+
824
+
825
+ def _surface_node(tree):
826
+ """The shader node Surface reaches, through reroutes and groups, or None."""
827
+ outputs = [n for n in tree.nodes
828
+ if n.bl_idname == "ShaderNodeOutputMaterial" and n.target in ("ALL", "EEVEE")]
829
+ output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
830
+ if output is None:
831
+ return None, None
832
+ return _follow(output.inputs["Surface"], [])
833
+
834
+
835
+ def _follow(socket, stack):
836
+ """The node an input socket's link comes from, through reroutes and groups,
837
+ as `(stack, node)`, or `(None, None)` when nothing is linked."""
838
+ while True:
839
+ links = _links_into(socket)
840
+ if not links:
841
+ return None, None
842
+ node, out = links[0].from_node, links[0].from_socket
843
+ if node.bl_idname == "NodeReroute":
844
+ socket = node.inputs[0]
845
+ elif node.bl_idname == "ShaderNodeGroup" and node.node_tree is not None:
846
+ inner = [n for n in node.node_tree.nodes if n.bl_idname == "NodeGroupOutput"]
847
+ if not inner:
848
+ return None, None
849
+ stack = stack + [node]
850
+ socket = next(s for s in inner[0].inputs if s.identifier == out.identifier)
851
+ elif node.bl_idname == "NodeGroupInput" and stack:
852
+ socket = next(s for s in stack[-1].inputs if s.identifier == out.identifier)
853
+ stack = stack[:-1]
854
+ else:
855
+ return stack, node
856
+
857
+
858
+ def _light_path_side(socket, stack):
859
+ """A Mix Shader factor that separates the camera's surface (a Light Path's
860
+ Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
861
+ as the input index the camera sees; None for any other factor. The door
862
+ collapses exactly these (`bpy_web_export.cc`)."""
863
+ links = _links_into(socket)
864
+ if not links:
865
+ value = socket.default_value
866
+ return 1 if value == 0.0 else 2 if value == 1.0 else None
867
+ link = links[0]
868
+ if link.from_node.bl_idname == "ShaderNodeLightPath":
869
+ return {"Is Shadow Ray": 1, "Is Camera Ray": 2}.get(link.from_socket.name)
870
+ return None
871
+
872
+
873
+ def _collapse(stack, node):
874
+ """Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
875
+ while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
876
+ side = _light_path_side(node.inputs[0], stack)
877
+ if side is None:
878
+ break
879
+ stack, node = _follow(node.inputs[side], stack)
880
+ return stack, node
881
+
882
+
883
+ def _closure(graph, stack, node, lit):
884
+ """A shader mix as the tree the presenter composes: `{"mix": [factor, a,
885
+ b]}`, `{"add": [a, b]}`, and its leaves `{"principled": true}`,
886
+ `{"emission": [color, strength]}`, `{"transparent": gray}` and `{"none":
887
+ true}` (an empty shader socket). The one Principled BSDF is appended to
888
+ `lit`; the door reduces its constants, and it reaches it only through
889
+ mixes and reroutes, never a group."""
890
+ stack, node = _collapse(stack, node)
891
+ if node is None:
892
+ return {"none": True}
893
+ if stack:
894
+ raise _GraphRefusal("its shader mix passes through group %s" % stack[-1].name)
895
+ if node.mute:
896
+ raise _GraphRefusal("%s is muted" % node.name)
897
+ kind = node.bl_idname
898
+ if kind in ("ShaderNodeMixShader", "ShaderNodeAddShader"):
899
+ sides = [_closure(graph, *_follow(node.inputs[i], stack), lit)
900
+ for i in ((1, 2) if kind == "ShaderNodeMixShader" else (0, 1))]
901
+ if kind == "ShaderNodeAddShader":
902
+ return {"add": sides}
903
+ return {"mix": [graph.source(stack, node.inputs[0])] + sides}
904
+ if kind == "ShaderNodeBsdfPrincipled":
905
+ if lit and lit[0] != node:
906
+ raise _GraphRefusal("it mixes two Principled BSDFs (%s and %s)" % (lit[0].name, node.name))
907
+ lit[:] = [node]
908
+ return {"principled": True}
909
+ if kind == "ShaderNodeEmission":
910
+ return {"emission": [graph.source(stack, node.inputs["Color"]),
911
+ graph.source(stack, node.inputs["Strength"])]}
912
+ if kind == "ShaderNodeBsdfTransparent":
913
+ color = node.inputs["Color"]
914
+ if color.is_linked:
915
+ raise _GraphRefusal("%s's Color is linked; a tinted transparency is not compiled" % node.name)
916
+ r, g, b = color.default_value[:3]
917
+ if not (r == g == b):
918
+ raise _GraphRefusal("%s is tinted; only a gray transparency is compiled" % node.name)
919
+ return {"transparent": float(r)}
920
+ raise _GraphRefusal("%s (%s) is not a shader the presenter composes" % (node.name, kind))
921
+
922
+
923
+ def material_graph(material):
924
+ """The graph the presenter compiles for this material, or None when the
925
+ door's reduction describes it fully (or the surface is not one the graph
926
+ covers). A graph outside `_GRAPH_NODES` is a warning naming the node."""
927
+ if not material.use_nodes or material.node_tree is None:
928
+ return None
929
+ stack, surface = _collapse(*_surface_node(material.node_tree))
930
+ if surface is not None and surface.bl_idname in ("ShaderNodeMixShader", "ShaderNodeAddShader"):
931
+ return _mixed_graph(material, stack, surface)
932
+ if surface is None or surface.bl_idname not in _GRAPH_SURFACES:
933
+ return None
934
+ sockets = [surface.inputs[name] for name in _GRAPH_SURFACES[surface.bl_idname]]
935
+ if not any(_links_into(s) for s in sockets):
936
+ return None
937
+ # The door is quiet about a material whose graph ships, so a linked input
938
+ # the graph does not carry is named here.
939
+ for other in surface.inputs:
940
+ linked = bool(_links_into(other))
941
+ if not linked or other in sockets:
942
+ continue
943
+ warn("%s: %s is linked; only its constant is drawn" % (material.name, other.name))
944
+ graph = _MaterialGraph(material)
945
+ try:
946
+ inputs = {s.name: graph.source(stack, s) for s in sockets}
947
+ except _GraphRefusal as refusal:
948
+ warn("%s: its graph is drawn as constants; %s" % (material.name, refusal))
949
+ return None
950
+ return {
951
+ "surface": surface.bl_idname,
952
+ "inputs": inputs,
953
+ "nodes": graph.nodes,
954
+ "images": sorted(graph.images),
955
+ "generated": graph.generated,
956
+ }
957
+
958
+
959
+ def _mixed_graph(material, stack, surface):
960
+ """A surface that mixes or adds shaders: the closure tree, and the one
961
+ Principled BSDF's graph-driven inputs (`_closure`)."""
962
+ graph = _MaterialGraph(material)
963
+ lit = []
964
+ try:
965
+ closure = _closure(graph, stack, surface, lit)
966
+ inputs = {}
967
+ if lit:
968
+ sockets = [lit[0].inputs[name] for name in _GRAPH_SURFACES["ShaderNodeBsdfPrincipled"]]
969
+ for other in lit[0].inputs:
970
+ if other not in sockets and _links_into(other):
971
+ warn("%s: %s is linked; only its constant is drawn" % (material.name, other.name))
972
+ inputs = {s.name: graph.source([], s) for s in sockets}
973
+ except _GraphRefusal as refusal:
974
+ warn("%s: its shader mix is not drawn; %s" % (material.name, refusal))
975
+ return None
976
+ return {
977
+ "surface": "ShaderNodeBsdfPrincipled" if lit else "closure",
978
+ "inputs": inputs,
979
+ "closure": closure,
980
+ "nodes": graph.nodes,
981
+ "images": sorted(graph.images),
982
+ "generated": graph.generated,
983
+ }
984
+
985
+
986
+ def material_graphs(scene):
987
+ """Every graph the scene's materials need, by material name."""
988
+ _TREE_LINKS.clear()
989
+ graphs = {}
990
+ for obj in scene.objects:
991
+ for slot in getattr(obj, "material_slots", ()):
992
+ material = slot.material
993
+ if material is not None and material.name not in graphs:
994
+ graphs[material.name] = material_graph(material)
995
+ return {name: graph for name, graph in graphs.items() if graph is not None}
996
+
997
+
529
998
  # ------------------------------------------------------------- the overlays
530
999
  #
531
1000
  # INSPECTION OVERLAYS, READ OFF THE ENGINE (ARCHITECTURE-CORE §Blender north
@@ -760,7 +1229,12 @@ class Session:
760
1229
  bones and the active vertex group's weights (`_armatures`,
761
1230
  `_weights`). All per-scene, none of them geometry.
762
1231
  """
763
- options = {"session": self.session, "evaluate": True, "known": self._known}
1232
+ scene = bpy.context.scene
1233
+ graphs = material_graphs(scene)
1234
+ options = {"session": self.session, "evaluate": True, "known": self._known,
1235
+ "graph_materials": sorted(graphs),
1236
+ "graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
1237
+ "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
764
1238
  # THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
765
1239
  # an ordered stream of filesystem patches that is the whole
766
1240
  # correctness argument: whatever carries `ask/<id>.done` out of this
@@ -784,7 +1258,36 @@ class Session:
784
1258
  # nine of 17-workshop-interior's 63 calls died on that refusal.
785
1259
  self.revision += 1
786
1260
  frame["revision"] = self.revision
787
- scene = bpy.context.scene
1261
+ # THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
1262
+ # named at the revision the door holds it at (`graph_images`, which is
1263
+ # the session's to read and not part of the presenter's frame).
1264
+ revisions = frame.pop("graph_images", {})
1265
+ for name, graph in graphs.items():
1266
+ if name not in frame["materials"]:
1267
+ continue
1268
+ for node in graph["nodes"].values():
1269
+ image = node["props"].get("image")
1270
+ if image is not None:
1271
+ node["props"]["image"] = {"name": image, "revision": int(revisions.get(image, 0))}
1272
+ del graph["images"]
1273
+ del graph["generated"]
1274
+ frame["materials"][name]["graph"] = graph
1275
+ # A MANUAL TEXTURE SPACE, which Generated coordinates map through; the
1276
+ # automatic one is the evaluated bounds the presenter already holds.
1277
+ if graphs:
1278
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1279
+ for row in frame["objects"]:
1280
+ if not any(m in graphs for m in row["materials"] if m is not None):
1281
+ continue
1282
+ obj = scene.objects.get(row["name"])
1283
+ data = obj.evaluated_get(depsgraph).data if obj is not None else None
1284
+ if getattr(data, "use_auto_texspace", True) is False:
1285
+ row["texspace"] = [[float(v) for v in data.texspace_location],
1286
+ [float(v) for v in data.texspace_size]]
1287
+ # The layer a Color Attribute node with no name reads.
1288
+ default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
1289
+ if default_color:
1290
+ row["default_color"] = default_color
788
1291
  frame["world"] = draw_world(scene)
789
1292
  frame["cameras"] = {
790
1293
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
@@ -840,16 +1343,15 @@ class Session:
840
1343
  # its value also depends on native undo initialization/checkpoints.
841
1344
  "dirty": bool(bpy.data.is_dirty),
842
1345
  }
843
- # A PRESENT IS WHAT LEAVES THE DOCUMENT STALE, and the predicate is the
844
- # present itself rather than what the frame shipped. `columns`/`images`
845
- # above count BYTES THAT CROSSED, which is a different question: a MOVE
846
- # ships zero columns (the comment above says so) and a DELETION ships
847
- # nothing at all, yet both are changes a document that loses them is
848
- # wrong. The cost this predicate could waste is bounded by the tab's
849
- # one-save-per-idle-second debounce, and `save_document` still asks
850
- # Blender whether anything actually changed before it writes.
851
- if self.document is not None:
852
- self.save_due = True
1346
+ # A PRESENT DOES NOT MAKE THE DOCUMENT STALE; a request that can change
1347
+ # the model does (`mark_changed`, from `dispatch`). The predicate was the
1348
+ # present itself, and a present also runs with no change behind it --
1349
+ # opening the document, rebinding its view. MEASURED 2026-09-24 on
1350
+ # `arena`: opening a Model document and doing nothing rewrote its
1351
+ # `.blend` with 556 bytes changed, so a file a person only looked at read
1352
+ # as modified. `columns`/`images` above stay the wrong question for the
1353
+ # same reason as before: a MOVE ships zero columns and a DELETION ships
1354
+ # nothing, and both come from a request `mark_changed` sees.
853
1355
  for key, mesh in frame["meshes"].items():
854
1356
  self._known["mesh:" + key] = mesh["revision"]
855
1357
  for name, image in frame["images"].items():
@@ -1037,6 +1539,12 @@ class Session:
1037
1539
  "objects": len(bpy.data.objects),
1038
1540
  "size": os.path.getsize(self.document)}
1039
1541
 
1542
+ def mark_changed(self):
1543
+ """A request that can change the model ran: the next present carries
1544
+ `saveDue`, and the tab saves after an idle second."""
1545
+ if self.document is not None:
1546
+ self.save_due = True
1547
+
1040
1548
  def save_document(self):
1041
1549
  """Write the document -- Blender's own format, by Blender's own operator.
1042
1550
 
@@ -1122,11 +1630,11 @@ class Session:
1122
1630
  #
1123
1631
  # It was briefly used to skip a redundant write, and it froze the
1124
1632
  # document after its first save -- a reopened session could model all
1125
- # day and never write again. The predicate is `_present`'s instead: a
1126
- # present is what leaves the document stale. Coarser (a read-only
1127
- # script rewrites the file once), bounded by the tab's idle-second
1128
- # debounce, and it cannot miss a change. The reading still rides in the
1129
- # answer, as an instrument.
1633
+ # day and never write again. The predicate is `mark_changed`'s instead:
1634
+ # a request that can change the model ran. Coarser than a diff (a
1635
+ # read-only script rewrites the file once), bounded by the tab's
1636
+ # idle-second debounce, and it cannot miss a change. The reading still
1637
+ # rides in the answer, as an instrument.
1130
1638
  dirty = bool(bpy.data.is_dirty)
1131
1639
  existed = os.path.exists(self.document)
1132
1640
  directory = os.path.dirname(self.document)
@@ -4137,6 +4645,7 @@ def dispatch(request):
4137
4645
  if op == "history-step":
4138
4646
  if request["direction"] not in ("undo", "redo"):
4139
4647
  raise ValueError("Unknown history direction")
4648
+ SESSION.mark_changed()
4140
4649
  return HISTORY.move(request["token"], request["direction"])
4141
4650
  mutation = op in ("execute", "rna-set", "outliner-set") and request.get("history", True)
4142
4651
  if not mutation:
@@ -4172,6 +4681,7 @@ def _dispatch(request):
4172
4681
  # Arbitrary Python can partially mutate before throwing. Once execution
4173
4682
  # starts it needs a checkpoint; capability refusals above do not.
4174
4683
  HISTORY.changed()
4684
+ SESSION.mark_changed()
4175
4685
  answer = execute(request["code"])
4176
4686
  # Every mutation is presented, the rule: the Model
4177
4687
  # document is what the agent is looking at.
@@ -4220,6 +4730,7 @@ def _dispatch(request):
4220
4730
  if op == "outliner":
4221
4731
  return rna_outliner(request.get("selected"))
4222
4732
  if op == "outliner-set":
4733
+ SESSION.mark_changed()
4223
4734
  answer = outliner_set(request["path"], request["column"], request["value"])
4224
4735
  # A COLUMN IS A WRITE, and a write presents -- the same rule `rna-set`
4225
4736
  # follows: hiding an object in the Outliner must change what the
@@ -4240,6 +4751,7 @@ def _dispatch(request):
4240
4751
  "showing the state before it: " + repr(thrown))
4241
4752
  return answer
4242
4753
  if op == "rna-set":
4754
+ SESSION.mark_changed()
4243
4755
  answer = rna_set(request["path"], request["property"], request["value"],
4244
4756
  request.get("index"))
4245
4757
  # A WRITE IS PRESENTED, exactly as `execute` presents: the Model
@@ -81,6 +81,10 @@ export interface MeshColumns {
81
81
  co: Float64Array;
82
82
  /** Blender-evaluated normals, 3 per corner, independent of UV splits. */
83
83
  cornerNormal?: Float32Array | undefined;
84
+ /** 3 * nv, a deformed mesh's Generated coordinates in Blender's stored
85
+ * -1..1 range (the door's `deformed_orco`); absent when the drawn
86
+ * positions are the undeformed ones. */
87
+ orco?: Float32Array | undefined;
84
88
  /** nf + 1 */
85
89
  faceStart: Uint32Array;
86
90
  /** faceStart[nf] entries: vertex index per loop */