"""
IFSRenderer for Blender — render fractal flames with Blender's GPU module.
Install this directory as an add-on (Edit > Preferences > Add-ons > Install from
Disk, pointing at a zip of it), enable "IFS Fractal Renderer", then open the
**IFS** tab in the 3D viewport sidebar (N).
The add-on is a thin shell over modules that are deliberately importable WITHOUT
Blender — `ifs_codegen`, `ifs_layout`, `ifs_host`, `ifs_document` are plain Python
and carry the test suite. Only `ifs_render` touches `gpu`. Keeping that split is
what lets 67 tests run in a bare interpreter, so put logic in the modules and
leave this file to properties, operators and panels.
Those modules import each other by bare name, so this inserts its own directory on
sys.path first. A relative-import rewrite would be churn for no gain and would
break running them standalone.
"""
bl_info = {
"name": "IFS Fractal Renderer",
"author": "IFSRenderer contributors",
"version": (0, 1, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > IFS",
"description": "Render IFS fractal flames on the GPU, from .ifsjson documents",
"category": "Render",
}
import os
import sys
import traceback
import bpy
from bpy.props import (
BoolProperty,
FloatVectorProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
IntProperty,
StringProperty,
)
from bpy.types import Operator, Panel, PropertyGroup, UIList
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
import ifs_codegen
import ifs_document
import ifs_hdri
import ifs_points
import ifs_render
import ifs_viewport
import ifs_volume
import ifs_nodes
def _assets_root() -> str:
"""
Where Transforms/ and the templates live.
Installed, the add-on carries its own copy beside `__init__.py`, so it works
without the repo. Run from a checkout there is no copy, and it reads the
repo's live files - which is what keeps development honest about the plugins
it actually compiles.
"""
bundled = os.path.join(_HERE, "assets")
if os.path.isdir(os.path.join(bundled, "Transforms")):
return bundled
return os.path.dirname(_HERE)
_REPO = _assets_root()
_TEMPLATES = os.path.join(_REPO, "WpfDisplay", "Library", "Templates")
# ifs_render derives its own asset root from its file location, which is the
# addons directory once installed. Point it at the resolved root instead.
ifs_render.REPO = _REPO
# The compiled plugin library. Loading it walks 46 files, so it is cached — but
# only until reload, so an edited .ifstf is picked up by toggling the add-on.
_library = None
def library():
global _library
if _library is None:
_library = ifs_codegen.load_library(
os.path.join(_REPO, "Transforms"),
os.path.join(_REPO, "WpfDisplay", "Library", "Includes"))
return _library
# ------------------------------------------------------------------- properties
def _touch(kind):
"""
Property update callback: flags the live session and asks for a redraw.
Edits only set a flag; the repack happens once in the draw handler. A slider
drag fires this on every pixel, so doing the work here would rebuild several
UBOs per mouse-move.
"""
def update(self, context):
if ifs_viewport.is_running():
ifs_viewport.session().mark_dirty(kind)
_redraw(context)
return update
# Sized from the shipped library: the widest plugin declares 7 real params (SSA)
# and 3 vec3 params (Affine, Mobius). Fixed-size vectors keep this a plain
# PropertyGroup; only the count each plugin declares is drawn.
MAX_REAL_UI = 8
MAX_VEC3_UI = 3
class IFSIteratorItem(PropertyGroup):
"""One iterator, mirrored from the loaded document and editable live."""
# A `name` property is required or every row in the UIList shows the same
# label — getname_anim_fcurve falls back to the type name without it.
name: StringProperty(name="Name", default="Iterator")
transform: StringProperty(name="Transform", default="")
opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0,
update=_touch('params'))
color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0,
update=_touch('params'))
color_speed: FloatProperty(name="Color speed", default=0.5, min=0.0, max=1.0,
update=_touch('params'))
# Changing a base weight re-normalises every xaos row, so the alias texture
# has to be rebuilt, not just the iterator block.
base_weight: FloatProperty(name="Weight", default=1.0, min=0.0, soft_max=10.0,
update=_touch('weights'))
real_values: FloatVectorProperty(size=MAX_REAL_UI, update=_touch('params'))
vec3_values: FloatVectorProperty(size=MAX_VEC3_UI * 3, update=_touch('params'))
real_names: StringProperty(default="") # comma-joined, for labelling
vec3_names: StringProperty(default="")
class IFSSceneSettings(PropertyGroup):
document: StringProperty(
name="Document", subtype='FILE_PATH',
description="An .ifsjson fractal, as saved by IFSRenderer")
resolution_x: IntProperty(name="Width", default=512, min=16, max=8192)
resolution_y: IntProperty(name="Height", default=512, min=16, max=8192)
dispatches: IntProperty(
name="Dispatches", default=132, min=1, max=100000,
description="Batches of 16384 x 500 iterations. More is cleaner and slower")
# A fractal flame is a density histogram, so only EMISSION is physically
# honest - the others invent a surface that is not there. All three are
# tonemap-only: they change how the accumulator is displayed, never the
# attractor, so switching never restarts accumulation.
shading_mode: EnumProperty(
name="Shading",
items=[
('EMISSION', "Emission", "The flame as light, added to the scene"),
('FLAT', "Flat", "Unlit palette colour composited over the scene"),
('METAL', "Metal", "Specular shading from the density gradient"),
],
default='EMISSION', update=_touch('tonemap'))
opacity_gain: FloatProperty(
name="Opacity", default=4.0, min=0.1, soft_max=32.0,
description="Coverage for the composited modes. Tonemapped density is "
"~0.03 across a nebula, so it needs lifting to be visible",
update=_touch('tonemap'))
relief: FloatProperty(
name="Relief", default=12.0, min=0.0, soft_max=64.0,
description="How pronounced the fake surface is in Metal shading",
update=_touch('tonemap'))
# Tonemap only - the accumulated samples stay valid, so dragging this does not
# restart the render.
brightness: FloatProperty(name="Brightness", default=8.0, min=0.01,
soft_max=64.0, max=1000.0, update=_touch('tonemap'))
use_document_resolution: BoolProperty(
name="Use document resolution", default=False,
description="Render at the size stored in the document instead of the above")
iters_per_frame: IntProperty(
name="Iterations/frame", default=400, min=10, max=20000,
description="Work per viewport redraw. Higher refines faster and drops the "
"framerate; the engine's own default is 500")
volume_size: IntProperty(
name="Voxels", default=192, min=32, max=512,
description="Grid is 4N x N x N uint32 on the GPU: 192 is ~113MB, "
"256 is ~268MB, 512 is ~2.1GB and will not fit")
volume_dispatches: IntProperty(name="Dispatches", default=200, min=1, max=100000)
volume_emission: FloatProperty(
name="Emission x",
description=("Multiplies the exposure derived from the bake. The scale itself is "
"computed from the fractal's own density along a ray, because the "
"figure differs by ~200x between documents - a fixed number blows one "
"to white and leaves another invisible. 1.0 is the derived exposure"),
default=1.0, min=0.0, soft_max=100.0)
volume_path: StringProperty(
name="VDB", subtype='FILE_PATH', default="//fractal.vdb")
# Points buy detail LINEARLY where a voxel grid buys it cubically, so the
# trade-off here is nothing like the volume's. Two RGBA32F images carry the
# cloud off the GPU, which is 32 bytes per point: 1M is ~32MB and 16M ~512MB,
# against 113MB for a 192-voxel grid that is already mush on thin filaments.
points_capacity: IntProperty(
name="Points", default=1_000_000, min=1024, max=16_000_000,
description="How many samples to keep. Costs 32 bytes each on the GPU: "
"1M is ~32MB. A run that wants more is truncated, and says so")
points_radius: FloatProperty(
name="Radius x", default=1.0, min=0.0, soft_max=10.0,
description="Multiplies the radius derived from the cloud's OWN measured "
"nearest-neighbour spacing, so 1.0 makes neighbouring points "
"just touch. How big a point should look also depends on how "
"close the camera is, which is why this exists")
points_emission: FloatProperty(
name="Emission", default=1.0, min=0.0, soft_max=100.0,
description="Emission strength of the point material. Unlike the volume's "
"exposure this needs no derivation - a point is a surface the "
"renderer sees directly, not a density integrated along a ray")
depth_bands: IntProperty(
name="Depth bands", default=4, min=1, max=16,
description="How finely the overlay is split by depth for compositing with "
"scene objects. 1 is a single buffer and occludes all-or-nothing "
"per pixel, which speckles where geometry sits inside the "
"fractal. Costs VRAM linearly: at 1920x1080 roughly 66 MB per "
"band",
update=_touch('params'))
hdri_width: IntProperty(
name="Width", default=2048, min=256, max=8192,
description="Equirectangular width. Height is half of it — u spans 360 "
"degrees of azimuth and v 180 of elevation, so square pixels "
"need a 2:1 image")
hdri_dispatches: IntProperty(
name="Dispatches", default=200, min=1, max=100000,
description="Batches of 16384 x 500 iterations. More is cleaner and slower")
hdri_set_world: BoolProperty(
name="Set as world background", default=True,
description="Point the World's Background node at the baked image. An "
"environment is infinitely far away, so it is never occluded "
"and it lights the scene in both EEVEE and Cycles")
iterators: CollectionProperty(type=IFSIteratorItem)
active_iterator: IntProperty(default=0)
status: StringProperty(default="")
# -------------------------------------------------------------------- operators
def _sync_iterators(settings, doc):
settings.iterators.clear()
plugins = {p.name: p for p in library().plugins}
for index, it in enumerate(doc.iterators):
name = _transform_name(it)
item = settings.iterators.add()
item.name = f"{index}: {name}"
item.transform = name
item.opacity = it.opacity
item.color_index = it.color_index
item.color_speed = it.color_speed
item.base_weight = it.base_weight
plugin = plugins.get(name)
if plugin is not None:
item.real_names = ",".join(plugin.real_params)
item.vec3_names = ",".join(plugin.vec3_params)
for i, value in enumerate(it.real_params[:MAX_REAL_UI]):
item.real_values[i] = value
for i, vec in enumerate(it.vec3_params[:MAX_VEC3_UI]):
for c in range(3):
item.vec3_values[i * 3 + c] = vec[c]
def _transform_name(iterator):
for name, tf_id in library().tf_ids.items():
if tf_id == iterator.tf_id:
return name
return f"tf{iterator.tf_id}"
class IFS_OT_load(Operator):
"""Load an .ifsjson document and list its iterators"""
bl_idname = "ifs.load_document"
bl_label = "Load Document"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
if not path or not os.path.isfile(path):
self.report({'ERROR'}, "Pick an .ifsjson file first")
return {'CANCELLED'}
try:
doc = ifs_document.load(path, library())
except Exception as ex: # a bad document must not take Blender down
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
_sync_iterators(settings, doc)
# Seed from the document, or the live session's sync replaces it with the
# property default on the first redraw. nebula_sample asks for 14.55.
settings.brightness = doc.brightness * 8.0
settings.status = (f"{doc.title}: {len(doc.iterators)} iterators, "
f"native {doc.width}x{doc.height}")
self.report({'INFO'}, settings.status)
return {'FINISHED'}
class IFS_OT_load_template(Operator):
"""Load one of the shipped templates"""
bl_idname = "ifs.load_template"
bl_label = "Load Template"
bl_property = "template"
def _items(self, context):
if not os.path.isdir(_TEMPLATES):
return [('NONE', "No templates found", "")]
return [(n, n[:-8].replace("_", " ").title(), "")
for n in sorted(os.listdir(_TEMPLATES)) if n.endswith(".ifsjson")]
template: EnumProperty(name="Template", items=_items)
def execute(self, context):
if self.template == 'NONE':
return {'CANCELLED'}
context.scene.ifs.document = os.path.join(_TEMPLATES, self.template)
return bpy.ops.ifs.load_document()
def invoke(self, context, event):
context.window_manager.invoke_search_popup(self)
return {'RUNNING_MODAL'}
class IFS_OT_viewport(Operator):
"""Start or stop live progressive rendering in the 3D viewport"""
bl_idname = "ifs.toggle_viewport"
bl_label = "Live Viewport"
def execute(self, context):
settings = context.scene.ifs
if ifs_viewport.is_running():
ifs_viewport.stop()
settings.status = "Live viewport stopped"
_redraw(context)
return {'FINISHED'}
path = bpy.path.abspath(settings.document)
if not path or not os.path.isfile(path):
self.report({'ERROR'}, "Pick an .ifsjson file first")
return {'CANCELLED'}
try:
ifs_viewport.start(path, iters_per_frame=settings.iters_per_frame)
except Exception as ex:
traceback.print_exc()
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
settings.status = "Live viewport running - orbit to look around"
_redraw(context)
return {'FINISHED'}
def _redraw(context):
for area in context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
def _assign_volume_material(obj, emission_scale):
"""
Gives the imported volume a material driven by the baked grids.
Without this the object has NO material at all, and a volume with no material is
very nearly invisible - measured, Cycles renders it at background brightness.
The load-bearing part is that DENSITY drives Emission Strength. Principled Volume's
emission does not scale with density, so a constant Strength makes every voxel emit
equally and discards the entire fractal, which lives in the density grid. That is
what produced a uniformly glowing ball; three renders across a 100x density change
came back byte-identical because nothing was responding to anything.
Scattering density is 0: a fractal flame is emission, not smoke. The colour grid
tints the emission, which is what carries the palette.
"""
material = bpy.data.materials.new("IFS Volume")
material.use_nodes = True
tree = material.node_tree
tree.nodes.clear()
output = tree.nodes.new('ShaderNodeOutputMaterial')
volume = tree.nodes.new('ShaderNodeVolumePrincipled')
volume.inputs['Density'].default_value = 0.0
colour = tree.nodes.new('ShaderNodeAttribute')
colour.attribute_name = "color"
tree.links.new(colour.outputs['Color'], volume.inputs['Emission Color'])
density = tree.nodes.new('ShaderNodeAttribute')
density.attribute_name = "density"
scale = tree.nodes.new('ShaderNodeMath')
scale.operation = 'MULTIPLY'
scale.inputs[1].default_value = emission_scale
tree.links.new(density.outputs['Fac'], scale.inputs[0])
tree.links.new(scale.outputs[0], volume.inputs['Emission Strength'])
tree.links.new(volume.outputs[0], output.inputs['Volume'])
obj.data.materials.append(material)
return material
class IFS_OT_bake_volume(Operator):
"""Bake the fractal to an OpenVDB volume and import it into the scene"""
bl_idname = "ifs.bake_volume"
bl_label = "Bake Volume"
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
# Prefer what is ON SCREEN. The node graph and the panel sliders both edit the
# live session's document, and baking from the file threw all of that away - so
# a bake could only ever reproduce the .ifsjson, and every edit made in Blender
# was silently ignored.
live = None
if ifs_viewport.is_running():
# Flush any pending graph edit before reading the document, or the bake
# gets the state from before the last node change.
ifs_viewport.session().apply_pending_graph()
live = ifs_viewport.session().doc
if live is None and (not path or not os.path.isfile(path)):
self.report({'ERROR'},
"Start the live viewport, or pick an .ifsjson file first")
return {'CANCELLED'}
out = bpy.path.abspath(settings.volume_path)
if not out.endswith(".vdb"):
out += ".vdb"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
try:
if live is not None:
written, stats = ifs_volume.bake_document(
live, out, size=settings.volume_size,
dispatches=settings.volume_dispatches)
else:
written, stats = ifs_volume.bake(
path, out, size=settings.volume_size,
dispatches=settings.volume_dispatches)
except Exception as ex:
traceback.print_exc()
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
bpy.ops.object.volume_import(filepath=written)
# context.object came back None at larger file sizes, so find it by type.
imported = [o for o in bpy.context.scene.objects if o.type == 'VOLUME']
if imported:
_assign_volume_material(
imported[-1],
stats.get("emission_scale", 1.0) * settings.volume_emission)
occupied = stats["occupied_voxels"]
# Says WHICH document was baked: the difference between the live edits and the
# file on disk is exactly what used to be invisible.
source = "live viewport" if live is not None else os.path.basename(path)
settings.status = (
f"Baked {occupied} voxels from the {source} to {os.path.basename(written)}")
if stats.get("clipped"):
self.report({'WARNING'},
"The fractal overflowed the probe box; the bake is clipped")
self.report({'INFO'}, settings.status)
return {'FINISHED'}
def _assign_points_material(obj, strength):
"""
Gives the point cloud an emission material tinted by the per-point colour.
The same lesson as `_assign_volume_material`, and it is worth repeating because
the symptom is indistinguishable from a broken emit: an object with NO material
renders at background brightness, so a perfectly good cloud looks like the
operator did nothing.
The attribute is `ifs_color`, NEVER `color`. That name is poisoned for points and
meshes in Blender - a per-point attribute called `color` renders black - while
being the EXPECTED name for a volume grid, which is what the volume material
above correctly reads. Copying that name here would produce a black cloud that
reads as a lighting problem rather than a naming one.
Emission rather than Principled: a fractal flame is light, and a point cloud is
geometry the renderer sees directly, so there is no density-along-a-ray to
correct for and none of the volume path's derived exposure is needed.
"""
material = bpy.data.materials.new("IFS Points")
material.use_nodes = True
tree = material.node_tree
tree.nodes.clear()
output = tree.nodes.new('ShaderNodeOutputMaterial')
emission = tree.nodes.new('ShaderNodeEmission')
emission.inputs['Strength'].default_value = strength
colour = tree.nodes.new('ShaderNodeAttribute')
colour.attribute_name = ifs_points.COLOR_ATTRIBUTE
tree.links.new(colour.outputs['Color'], emission.inputs['Color'])
tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
obj.data.materials.append(material)
return material
class IFS_OT_emit_points(Operator):
"""Emit the fractal as a point cloud that Cycles and EEVEE both render"""
bl_idname = "ifs.emit_points"
bl_label = "Emit Point Cloud"
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
# Prefer what is ON SCREEN, for the reason the volume bake does: the node
# graph and the panel sliders write into the live session's document, so
# reading the file would silently discard every edit made inside Blender.
live = None
if ifs_viewport.is_running():
ifs_viewport.session().apply_pending_graph()
live = ifs_viewport.session().doc
if live is None and (not path or not os.path.isfile(path)):
self.report({'ERROR'},
"Start the live viewport, or pick an .ifsjson file first")
return {'CANCELLED'}
try:
if live is not None:
positions, colors, stats = ifs_points.emit_document(
live, capacity=settings.points_capacity)
else:
positions, colors, stats = ifs_points.emit_from_file(
path, capacity=settings.points_capacity)
except Exception as ex:
traceback.print_exc()
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
cloud = ifs_points.to_pointcloud(
positions, colors, stats["radius"] * settings.points_radius)
obj = bpy.data.objects.new("IFS Points", cloud)
context.collection.objects.link(obj)
_assign_points_material(obj, settings.points_emission)
stored = stats["points"]
source = "live viewport" if live is not None else os.path.basename(path)
settings.status = f"Emitted {stored} points from the {source}"
# Say so when the cloud is a prefix of the run. A cloud truncated at a
# million of twelve million samples is still a convincing fractal, so this
# is invisible unless it is reported - the emitter counts PAST the capacity
# for exactly this reason.
requested = stats.get("requested", 0)
if stats.get("filled") and requested > stored:
settings.status += f" - truncated from the {requested} it wanted"
self.report({'WARNING'},
f"The capacity held {stored} of {requested} samples; raise "
"Points for the rest")
self.report({'INFO'}, settings.status)
return {'FINISHED'}
def _wire_world(image):
"""
Points the World at the baked image.
Rebuilt rather than patched: a World may carry anything, and hunting for an
existing Environment Texture to repoint is how you end up wired to the node
the user was actually using for something else.
"""
world = bpy.context.scene.world
if world is None:
world = bpy.data.worlds.new("IFS World")
bpy.context.scene.world = world
world.use_nodes = True
tree = world.node_tree
tree.nodes.clear()
output = tree.nodes.new('ShaderNodeOutputWorld')
background = tree.nodes.new('ShaderNodeBackground')
env = tree.nodes.new('ShaderNodeTexEnvironment')
env.image = image
tree.links.new(env.outputs['Color'], background.inputs['Color'])
tree.links.new(background.outputs['Background'], output.inputs['Surface'])
return world
class IFS_OT_bake_hdri(Operator):
"""Bake the fractal to an equirectangular environment map"""
bl_idname = "ifs.bake_hdri"
bl_label = "Bake Environment"
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
# Prefer what is ON SCREEN, as every other bake does: the panel and the
# node graph write into the live session's document.
live = None
if ifs_viewport.is_running():
ifs_viewport.session().apply_pending_graph()
live = ifs_viewport.session().doc
if live is None and (not path or not os.path.isfile(path)):
self.report({'ERROR'},
"Start the live viewport, or pick an .ifsjson file first")
return {'CANCELLED'}
width = settings.hdri_width
height = max(1, width // 2)
try:
shaders = ifs_hdri.build_shader(width, height)
doc = live if live is not None else ifs_document.load(path, shaders[2])
pixels, stats = ifs_hdri.bake_document(
doc, width, height, dispatches=settings.hdri_dispatches,
frame=context.scene.frame_current, shaders=shaders)
image = ifs_hdri.to_image(pixels)
except Exception as ex:
traceback.print_exc()
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
if settings.hdri_set_world:
_wire_world(image)
source = "live viewport" if live is not None else os.path.basename(path)
settings.status = (f"Baked {width}x{height} environment from the {source} "
f"(frame {stats['frame']})")
self.report({'INFO'}, settings.status)
return {'FINISHED'}
class IFS_OT_render(Operator):
"""Render the loaded fractal and show it in the Image Editor"""
bl_idname = "ifs.render"
bl_label = "Render Fractal"
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
if not path or not os.path.isfile(path):
self.report({'ERROR'}, "Pick an .ifsjson file first")
return {'CANCELLED'}
width, height = settings.resolution_x, settings.resolution_y
if settings.use_document_resolution:
try:
doc = ifs_document.load(path, library())
width, height = doc.width, doc.height
except Exception as ex:
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
try:
name = ifs_render.render(
width=width, height=height, dispatches=settings.dispatches,
brightness=settings.brightness, input_path=path, out_path=None)
except Exception as ex:
# The GPU path can fail in ways worth seeing in full, so the traceback
# goes to the console and the summary to the status bar.
traceback.print_exc()
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
_show_image(context, name)
settings.status = f"Rendered {width}x{height}"
self.report({'INFO'}, settings.status)
return {'FINISHED'}
def _show_image(context, name):
"""Points an open Image Editor at the result, if there is one."""
image = bpy.data.images.get(name)
if image is None:
return
for area in context.screen.areas:
if area.type == 'IMAGE_EDITOR':
area.spaces.active.image = image
return
# ----------------------------------------------------------------------- panels
class IFS_UL_iterators(UIList):
def draw_item(self, context, layout, data, item, icon, active_data,
active_propname, index):
row = layout.row(align=True)
row.label(text=item.name)
row.prop(item, "opacity", text="", slider=True)
class IFS_PT_main(Panel):
bl_label = "IFS Fractal"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "IFS"
def draw(self, context):
layout = self.layout
settings = context.scene.ifs
column = layout.column(align=True)
column.prop(settings, "document", text="")
row = column.row(align=True)
row.operator("ifs.load_document", icon='FILE_REFRESH')
row.operator("ifs.load_template", text="", icon='PRESET')
if settings.status:
layout.label(text=settings.status, icon='INFO')
box = layout.box()
box.label(text="Output")
box.prop(settings, "use_document_resolution")
sub = box.column(align=True)
sub.enabled = not settings.use_document_resolution
sub.prop(settings, "resolution_x")
sub.prop(settings, "resolution_y")
box.prop(settings, "dispatches")
box.prop(settings, "brightness")
layout.operator("ifs.render", icon='RENDER_STILL')
layout.operator("ifs.build_graph", icon='NODETREE')
box = layout.box()
box.label(text="Live Viewport")
box.prop(settings, "shading_mode", text="")
if settings.shading_mode != 'EMISSION':
box.prop(settings, "opacity_gain")
if settings.shading_mode == 'METAL':
box.prop(settings, "relief")
box.prop(settings, "iters_per_frame")
box.prop(settings, "depth_bands")
running = ifs_viewport.is_running()
box.operator("ifs.toggle_viewport",
text="Stop" if running else "Start",
icon='PAUSE' if running else 'PLAY',
depress=running)
if running:
box.label(text=ifs_viewport.status(), icon='INFO')
box = layout.box()
box.label(text="Volume (renders in Cycles)")
box.prop(settings, "volume_size")
box.prop(settings, "volume_dispatches")
box.prop(settings, "volume_path")
box.operator("ifs.bake_volume", icon='OUTLINER_OB_VOLUME')
box = layout.box()
box.label(text="Point Cloud (Cycles and EEVEE)")
box.prop(settings, "points_capacity")
box.prop(settings, "points_radius")
box.prop(settings, "points_emission")
box.operator("ifs.emit_points", icon='OUTLINER_OB_POINTCLOUD')
box = layout.box()
box.label(text="Environment (lights the scene)")
box.prop(settings, "hdri_width")
box.prop(settings, "hdri_dispatches")
box.prop(settings, "hdri_set_world")
box.operator("ifs.bake_hdri", icon='WORLD')
class IFS_PT_iterators(Panel):
bl_label = "Iterators"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "IFS"
bl_parent_id = "IFS_PT_main"
def draw(self, context):
settings = context.scene.ifs
if not settings.iterators:
self.layout.label(text="Load a document to list its iterators")
return
self.layout.template_list(
"IFS_UL_iterators", "", settings, "iterators",
settings, "active_iterator", rows=4)
if not (0 <= settings.active_iterator < len(settings.iterators)):
return
item = settings.iterators[settings.active_iterator]
column = self.layout.column(align=True)
column.label(text=f"Transform: {item.transform}")
column.prop(item, "opacity", slider=True)
column.prop(item, "color_index", slider=True)
column.prop(item, "color_speed", slider=True)
column.prop(item, "base_weight")
# Only the parameters this plugin actually declares, under their own names.
real_names = [n for n in item.real_names.split(",") if n]
vec3_names = [n for n in item.vec3_names.split(",") if n]
if real_names or vec3_names:
box = self.layout.box()
box.label(text=f"{item.transform} parameters")
for i, pname in enumerate(real_names[:MAX_REAL_UI]):
box.prop(item, "real_values", index=i, text=pname)
for i, pname in enumerate(vec3_names[:MAX_VEC3_UI]):
row = box.row(align=True)
row.label(text=pname)
for c in range(3):
row.prop(item, "vec3_values", index=i * 3 + c, text="")
if not ifs_viewport.is_running():
self.layout.label(text="Start the live viewport to see edits",
icon='INFO')
# --------------------------------------------------------------------- register
_CLASSES = (
IFSIteratorItem,
IFSSceneSettings,
IFS_OT_load,
IFS_OT_load_template,
IFS_OT_render,
IFS_OT_viewport,
IFS_OT_bake_volume,
IFS_OT_emit_points,
IFS_OT_bake_hdri,
IFS_UL_iterators,
IFS_PT_main,
IFS_PT_iterators,
)
def register():
for cls in _CLASSES:
bpy.utils.register_class(cls)
ifs_nodes.register()
ifs_viewport.install_handlers()
bpy.types.Scene.ifs = bpy.props.PointerProperty(type=IFSSceneSettings)
def unregister():
# A live draw handler outlives the add-on and crashes on the next redraw.
ifs_viewport.stop()
ifs_viewport.remove_handlers()
ifs_nodes.unregister()
del bpy.types.Scene.ifs
for cls in reversed(_CLASSES):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()