@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.
- package/browser/blender-emscripten-engine.mts +57 -6
- package/browser/blender-engine.mts +2 -0
- package/browser/runtime.ts +10 -5
- package/browser/session.py +529 -17
- package/browser/three/blender-frame-columns.ts +4 -0
- package/browser/three/blender-graph-material.ts +406 -0
- package/browser/three/blender-node-glsl.generated.ts +401 -0
- package/browser/three/blender-node-graph.ts +1026 -0
- package/browser/three/blender-physical-material.ts +42 -5
- package/browser/three/blender-presenter-change.ts +24 -0
- package/browser/three/blender-runtime-geometry.ts +86 -6
- package/browser/three/blender-runtime-lighting.ts +3 -0
- package/browser/three/blender-runtime-view.ts +173 -5
- package/browser/three/blender-texture-samplers.ts +2 -0
- package/browser/worker.ts +3 -0
- package/package.json +1 -1
- package/wasm/BUNDLE.json +11 -31
- package/wasm/blender_browser.wasm.br +0 -0
package/browser/session.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
844
|
-
#
|
|
845
|
-
#
|
|
846
|
-
#
|
|
847
|
-
#
|
|
848
|
-
#
|
|
849
|
-
#
|
|
850
|
-
#
|
|
851
|
-
|
|
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 `
|
|
1126
|
-
#
|
|
1127
|
-
# script rewrites the file once), bounded by the tab's
|
|
1128
|
-
# debounce, and it cannot miss a change. The reading still
|
|
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 */
|