| … | … | @@ -0,0 +1,338 @@ |
| 1 | """ |
| 2 | IFSRenderer for Blender — render fractal flames with Blender's GPU module. |
| 3 | |
| 4 | Install this directory as an add-on (Edit > Preferences > Add-ons > Install from |
| 5 | Disk, pointing at a zip of it), enable "IFS Fractal Renderer", then open the |
| 6 | **IFS** tab in the 3D viewport sidebar (N). |
| 7 | |
| 8 | The add-on is a thin shell over modules that are deliberately importable WITHOUT |
| 9 | Blender — `ifs_codegen`, `ifs_layout`, `ifs_host`, `ifs_document` are plain Python |
| 10 | and carry the test suite. Only `ifs_render` touches `gpu`. Keeping that split is |
| 11 | what lets 67 tests run in a bare interpreter, so put logic in the modules and |
| 12 | leave this file to properties, operators and panels. |
| 13 | |
| 14 | Those modules import each other by bare name, so this inserts its own directory on |
| 15 | sys.path first. A relative-import rewrite would be churn for no gain and would |
| 16 | break running them standalone. |
| 17 | """ |
| 18 | |
| 19 | bl_info = { |
| 20 | "name": "IFS Fractal Renderer", |
| 21 | "author": "IFSRenderer contributors", |
| 22 | "version": (0, 1, 0), |
| 23 | "blender": (4, 2, 0), |
| 24 | "location": "View3D > Sidebar > IFS", |
| 25 | "description": "Render IFS fractal flames on the GPU, from .ifsjson documents", |
| 26 | "category": "Render", |
| 27 | } |
| 28 | |
| 29 | import os |
| 30 | import sys |
| 31 | import traceback |
| 32 | |
| 33 | import bpy |
| 34 | from bpy.props import ( |
| 35 | BoolProperty, |
| 36 | CollectionProperty, |
| 37 | EnumProperty, |
| 38 | FloatProperty, |
| 39 | IntProperty, |
| 40 | StringProperty, |
| 41 | ) |
| 42 | from bpy.types import Operator, Panel, PropertyGroup, UIList |
| 43 | |
| 44 | _HERE = os.path.dirname(os.path.abspath(__file__)) |
| 45 | if _HERE not in sys.path: |
| 46 | sys.path.insert(0, _HERE) |
| 47 | |
| 48 | import ifs_codegen |
| 49 | import ifs_document |
| 50 | import ifs_render |
| 51 | |
| 52 | def _assets_root() -> str: |
| 53 | """ |
| 54 | Where Transforms/ and the templates live. |
| 55 | |
| 56 | Installed, the add-on carries its own copy beside `__init__.py`, so it works |
| 57 | without the repo. Run from a checkout there is no copy, and it reads the |
| 58 | repo's live files - which is what keeps development honest about the plugins |
| 59 | it actually compiles. |
| 60 | """ |
| 61 | bundled = os.path.join(_HERE, "assets") |
| 62 | if os.path.isdir(os.path.join(bundled, "Transforms")): |
| 63 | return bundled |
| 64 | return os.path.dirname(_HERE) |
| 65 | |
| 66 | |
| 67 | _REPO = _assets_root() |
| 68 | _TEMPLATES = os.path.join(_REPO, "WpfDisplay", "Library", "Templates") |
| 69 | |
| 70 | # ifs_render derives its own asset root from its file location, which is the |
| 71 | # addons directory once installed. Point it at the resolved root instead. |
| 72 | ifs_render.REPO = _REPO |
| 73 | |
| 74 | # The compiled plugin library. Loading it walks 46 files, so it is cached — but |
| 75 | # only until reload, so an edited .ifstf is picked up by toggling the add-on. |
| 76 | _library = None |
| 77 | |
| 78 | |
| 79 | def library(): |
| 80 | global _library |
| 81 | if _library is None: |
| 82 | _library = ifs_codegen.load_library( |
| 83 | os.path.join(_REPO, "Transforms"), |
| 84 | os.path.join(_REPO, "WpfDisplay", "Library", "Includes")) |
| 85 | return _library |
| 86 | |
| 87 | |
| 88 | # ------------------------------------------------------------------- properties |
| 89 | |
| 90 | class IFSIteratorItem(PropertyGroup): |
| 91 | """One iterator, mirrored from the loaded document for display and tweaking.""" |
| 92 | |
| 93 | # A `name` property is required or every row in the UIList shows the same |
| 94 | # label — getname_anim_fcurve falls back to the type name without it. |
| 95 | name: StringProperty(name="Name", default="Iterator") |
| 96 | transform: StringProperty(name="Transform", default="") |
| 97 | opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0) |
| 98 | color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0) |
| 99 | base_weight: FloatProperty(name="Weight", default=1.0, min=0.0) |
| 100 | |
| 101 | |
| 102 | class IFSSceneSettings(PropertyGroup): |
| 103 | document: StringProperty( |
| 104 | name="Document", subtype='FILE_PATH', |
| 105 | description="An .ifsjson fractal, as saved by IFSRenderer") |
| 106 | resolution_x: IntProperty(name="Width", default=512, min=16, max=8192) |
| 107 | resolution_y: IntProperty(name="Height", default=512, min=16, max=8192) |
| 108 | dispatches: IntProperty( |
| 109 | name="Dispatches", default=132, min=1, max=100000, |
| 110 | description="Batches of 16384 x 500 iterations. More is cleaner and slower") |
| 111 | brightness: FloatProperty(name="Brightness", default=8.0, min=0.01, max=1000.0) |
| 112 | use_document_resolution: BoolProperty( |
| 113 | name="Use document resolution", default=False, |
| 114 | description="Render at the size stored in the document instead of the above") |
| 115 | iterators: CollectionProperty(type=IFSIteratorItem) |
| 116 | active_iterator: IntProperty(default=0) |
| 117 | status: StringProperty(default="") |
| 118 | |
| 119 | |
| 120 | # -------------------------------------------------------------------- operators |
| 121 | |
| 122 | def _sync_iterators(settings, doc): |
| 123 | settings.iterators.clear() |
| 124 | for index, it in enumerate(doc.iterators): |
| 125 | item = settings.iterators.add() |
| 126 | item.name = f"{index}: {_transform_name(it)}" |
| 127 | item.transform = _transform_name(it) |
| 128 | item.opacity = it.opacity |
| 129 | item.color_index = it.color_index |
| 130 | item.base_weight = it.base_weight |
| 131 | |
| 132 | |
| 133 | def _transform_name(iterator): |
| 134 | for name, tf_id in library().tf_ids.items(): |
| 135 | if tf_id == iterator.tf_id: |
| 136 | return name |
| 137 | return f"tf{iterator.tf_id}" |
| 138 | |
| 139 | |
| 140 | class IFS_OT_load(Operator): |
| 141 | """Load an .ifsjson document and list its iterators""" |
| 142 | |
| 143 | bl_idname = "ifs.load_document" |
| 144 | bl_label = "Load Document" |
| 145 | bl_options = {'REGISTER', 'UNDO'} |
| 146 | |
| 147 | def execute(self, context): |
| 148 | settings = context.scene.ifs |
| 149 | path = bpy.path.abspath(settings.document) |
| 150 | if not path or not os.path.isfile(path): |
| 151 | self.report({'ERROR'}, "Pick an .ifsjson file first") |
| 152 | return {'CANCELLED'} |
| 153 | try: |
| 154 | doc = ifs_document.load(path, library()) |
| 155 | except Exception as ex: # a bad document must not take Blender down |
| 156 | self.report({'ERROR'}, f"{type(ex).__name__}: {ex}") |
| 157 | return {'CANCELLED'} |
| 158 | |
| 159 | _sync_iterators(settings, doc) |
| 160 | settings.status = (f"{doc.title}: {len(doc.iterators)} iterators, " |
| 161 | f"native {doc.width}x{doc.height}") |
| 162 | self.report({'INFO'}, settings.status) |
| 163 | return {'FINISHED'} |
| 164 | |
| 165 | |
| 166 | class IFS_OT_load_template(Operator): |
| 167 | """Load one of the shipped templates""" |
| 168 | |
| 169 | bl_idname = "ifs.load_template" |
| 170 | bl_label = "Load Template" |
| 171 | bl_property = "template" |
| 172 | |
| 173 | def _items(self, context): |
| 174 | if not os.path.isdir(_TEMPLATES): |
| 175 | return [('NONE', "No templates found", "")] |
| 176 | return [(n, n[:-8].replace("_", " ").title(), "") |
| 177 | for n in sorted(os.listdir(_TEMPLATES)) if n.endswith(".ifsjson")] |
| 178 | |
| 179 | template: EnumProperty(name="Template", items=_items) |
| 180 | |
| 181 | def execute(self, context): |
| 182 | if self.template == 'NONE': |
| 183 | return {'CANCELLED'} |
| 184 | context.scene.ifs.document = os.path.join(_TEMPLATES, self.template) |
| 185 | return bpy.ops.ifs.load_document() |
| 186 | |
| 187 | def invoke(self, context, event): |
| 188 | context.window_manager.invoke_search_popup(self) |
| 189 | return {'RUNNING_MODAL'} |
| 190 | |
| 191 | |
| 192 | class IFS_OT_render(Operator): |
| 193 | """Render the loaded fractal and show it in the Image Editor""" |
| 194 | |
| 195 | bl_idname = "ifs.render" |
| 196 | bl_label = "Render Fractal" |
| 197 | |
| 198 | def execute(self, context): |
| 199 | settings = context.scene.ifs |
| 200 | path = bpy.path.abspath(settings.document) |
| 201 | if not path or not os.path.isfile(path): |
| 202 | self.report({'ERROR'}, "Pick an .ifsjson file first") |
| 203 | return {'CANCELLED'} |
| 204 | |
| 205 | width, height = settings.resolution_x, settings.resolution_y |
| 206 | if settings.use_document_resolution: |
| 207 | try: |
| 208 | doc = ifs_document.load(path, library()) |
| 209 | width, height = doc.width, doc.height |
| 210 | except Exception as ex: |
| 211 | self.report({'ERROR'}, f"{type(ex).__name__}: {ex}") |
| 212 | return {'CANCELLED'} |
| 213 | |
| 214 | try: |
| 215 | name = ifs_render.render( |
| 216 | width=width, height=height, dispatches=settings.dispatches, |
| 217 | brightness=settings.brightness, input_path=path, out_path=None) |
| 218 | except Exception as ex: |
| 219 | # The GPU path can fail in ways worth seeing in full, so the traceback |
| 220 | # goes to the console and the summary to the status bar. |
| 221 | traceback.print_exc() |
| 222 | self.report({'ERROR'}, f"{type(ex).__name__}: {ex}") |
| 223 | return {'CANCELLED'} |
| 224 | |
| 225 | _show_image(context, name) |
| 226 | settings.status = f"Rendered {width}x{height}" |
| 227 | self.report({'INFO'}, settings.status) |
| 228 | return {'FINISHED'} |
| 229 | |
| 230 | |
| 231 | def _show_image(context, name): |
| 232 | """Points an open Image Editor at the result, if there is one.""" |
| 233 | image = bpy.data.images.get(name) |
| 234 | if image is None: |
| 235 | return |
| 236 | for area in context.screen.areas: |
| 237 | if area.type == 'IMAGE_EDITOR': |
| 238 | area.spaces.active.image = image |
| 239 | return |
| 240 | |
| 241 | |
| 242 | # ----------------------------------------------------------------------- panels |
| 243 | |
| 244 | class IFS_UL_iterators(UIList): |
| 245 | def draw_item(self, context, layout, data, item, icon, active_data, |
| 246 | active_propname, index): |
| 247 | row = layout.row(align=True) |
| 248 | row.label(text=item.name) |
| 249 | row.prop(item, "opacity", text="", slider=True) |
| 250 | |
| 251 | |
| 252 | class IFS_PT_main(Panel): |
| 253 | bl_label = "IFS Fractal" |
| 254 | bl_space_type = 'VIEW_3D' |
| 255 | bl_region_type = 'UI' |
| 256 | bl_category = "IFS" |
| 257 | |
| 258 | def draw(self, context): |
| 259 | layout = self.layout |
| 260 | settings = context.scene.ifs |
| 261 | |
| 262 | column = layout.column(align=True) |
| 263 | column.prop(settings, "document", text="") |
| 264 | row = column.row(align=True) |
| 265 | row.operator("ifs.load_document", icon='FILE_REFRESH') |
| 266 | row.operator("ifs.load_template", text="", icon='PRESET') |
| 267 | |
| 268 | if settings.status: |
| 269 | layout.label(text=settings.status, icon='INFO') |
| 270 | |
| 271 | box = layout.box() |
| 272 | box.label(text="Output") |
| 273 | box.prop(settings, "use_document_resolution") |
| 274 | sub = box.column(align=True) |
| 275 | sub.enabled = not settings.use_document_resolution |
| 276 | sub.prop(settings, "resolution_x") |
| 277 | sub.prop(settings, "resolution_y") |
| 278 | box.prop(settings, "dispatches") |
| 279 | box.prop(settings, "brightness") |
| 280 | |
| 281 | layout.operator("ifs.render", icon='RENDER_STILL') |
| 282 | |
| 283 | |
| 284 | class IFS_PT_iterators(Panel): |
| 285 | bl_label = "Iterators" |
| 286 | bl_space_type = 'VIEW_3D' |
| 287 | bl_region_type = 'UI' |
| 288 | bl_category = "IFS" |
| 289 | bl_parent_id = "IFS_PT_main" |
| 290 | |
| 291 | def draw(self, context): |
| 292 | settings = context.scene.ifs |
| 293 | if not settings.iterators: |
| 294 | self.layout.label(text="Load a document to list its iterators") |
| 295 | return |
| 296 | |
| 297 | self.layout.template_list( |
| 298 | "IFS_UL_iterators", "", settings, "iterators", |
| 299 | settings, "active_iterator", rows=4) |
| 300 | |
| 301 | if 0 <= settings.active_iterator < len(settings.iterators): |
| 302 | item = settings.iterators[settings.active_iterator] |
| 303 | column = self.layout.column(align=True) |
| 304 | column.label(text=f"Transform: {item.transform}") |
| 305 | column.prop(item, "color_index") |
| 306 | column.prop(item, "base_weight") |
| 307 | column.label(text="Edits are not fed back to the render yet", |
| 308 | icon='ERROR') |
| 309 | |
| 310 | |
| 311 | # --------------------------------------------------------------------- register |
| 312 | |
| 313 | _CLASSES = ( |
| 314 | IFSIteratorItem, |
| 315 | IFSSceneSettings, |
| 316 | IFS_OT_load, |
| 317 | IFS_OT_load_template, |
| 318 | IFS_OT_render, |
| 319 | IFS_UL_iterators, |
| 320 | IFS_PT_main, |
| 321 | IFS_PT_iterators, |
| 322 | ) |
| 323 | |
| 324 | |
| 325 | def register(): |
| 326 | for cls in _CLASSES: |
| 327 | bpy.utils.register_class(cls) |
| 328 | bpy.types.Scene.ifs = bpy.props.PointerProperty(type=IFSSceneSettings) |
| 329 | |
| 330 | |
| 331 | def unregister(): |
| 332 | del bpy.types.Scene.ifs |
| 333 | for cls in reversed(_CLASSES): |
| 334 | bpy.utils.unregister_class(cls) |
| 335 | |
| 336 | |
| 337 | if __name__ == "__main__": |
| 338 | register() |