OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 0fbf04c

Ship it as an installable Blender add-on
Install the zip, enable "IFS Fractal Renderer", open the IFS tab in the 3D viewport sidebar: pick a template, hit Render, the result appears in the Image Editor. python3 blender/build_addon.py --out /tmp/ifs_fractal.zip The add-on is a thin shell - properties, three operators, two panels. All the logic stays in modules that are importable WITHOUT Blender, which is what lets 67 tests run in a bare interpreter; only ifs_render touches `gpu`. Keeping that split is the point, so new logic belongs in the modules. Two things the install path taught me, neither of which shows up when running the script from a checkout: - **An installed add-on cannot see the repo.** It lands in ~/.config/blender/5.2/scripts/addons/ifs_fractal/, where `os.path.dirname(__file__)` is the addons directory - no Transforms/, no templates. The first build enabled cleanly and then failed on every operator. The zip is now self-contained: 63 files, 60 KB, carrying the 46 plugins, the includes, the kernel template and the templates under assets/. From a checkout there is no bundled assets/ and it reads the repo's live files, so development still compiles what is actually on disk. - **ifs_render derives its own asset root** from its file location and needed the same treatment, which the first fix missed because the add-on's own paths were already right. Verified by actually installing: addon_install from the zip, addon_enable, load_template, render - not by reasoning about it. Also checked that a bad document path reports an error rather than taking Blender down. Renders now go into a Blender Image datablock ("IFS Render"), reused across re-renders when the size matches so an open editor does not go blank. Writing a PNG is optional and only the CLI path does it. Known gaps, all visible in the UI rather than hidden: iterator edits in the panel are display-only and not fed back to the render (the panel says so), there is no interactive viewport yet, and the tonemap still does not match the engine (correlation 0.834). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0fbf04caa1aee437d84c162f728c5be1bcd9cfc1
by plankatron <fleeky@gmail.com> · 2026-08-16T22:42:39.000Z

Changed files (4) +465 -10

blender/.gitignore +2 -0
……@@ -0,0 +1,2 @@
1ifs_fractal.zip
2__pycache__/
blender/__init__.py +338 -0
……@@ -0,0 +1,338 @@
1"""
2IFSRenderer for Blender — render fractal flames with Blender's GPU module.
3
4Install this directory as an add-on (Edit > Preferences > Add-ons > Install from
5Disk, pointing at a zip of it), enable "IFS Fractal Renderer", then open the
6**IFS** tab in the 3D viewport sidebar (N).
7
8The add-on is a thin shell over modules that are deliberately importable WITHOUT
9Blender — `ifs_codegen`, `ifs_layout`, `ifs_host`, `ifs_document` are plain Python
10and carry the test suite. Only `ifs_render` touches `gpu`. Keeping that split is
11what lets 67 tests run in a bare interpreter, so put logic in the modules and
12leave this file to properties, operators and panels.
13
14Those modules import each other by bare name, so this inserts its own directory on
15sys.path first. A relative-import rewrite would be churn for no gain and would
16break running them standalone.
17"""
18
19bl_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
29import os
30import sys
31import traceback
32
33import bpy
34from bpy.props import (
35 BoolProperty,
36 CollectionProperty,
37 EnumProperty,
38 FloatProperty,
39 IntProperty,
40 StringProperty,
41)
42from bpy.types import Operator, Panel, PropertyGroup, UIList
43
44_HERE = os.path.dirname(os.path.abspath(__file__))
45if _HERE not in sys.path:
46 sys.path.insert(0, _HERE)
47
48import ifs_codegen
49import ifs_document
50import ifs_render
51
52def _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.
72ifs_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
79def 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
90class 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
102class 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
122def _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
133def _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
140class 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
166class 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
192class 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
231def _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
244class 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
252class 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
284class 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
325def register():
326 for cls in _CLASSES:
327 bpy.utils.register_class(cls)
328 bpy.types.Scene.ifs = bpy.props.PointerProperty(type=IFSSceneSettings)
329
330
331def unregister():
332 del bpy.types.Scene.ifs
333 for cls in reversed(_CLASSES):
334 bpy.utils.unregister_class(cls)
335
336
337if __name__ == "__main__":
338 register()
blender/build_addon.py +96 -0
……@@ -0,0 +1,96 @@
1"""
2Packages the add-on into a zip Blender will install.
3
4 python3 blender/build_addon.py --out /tmp/ifs_fractal.zip
5
6Blender installs a zip whose TOP LEVEL is a single directory containing
7`__init__.py`, and the directory name becomes the module name. This repo's folder
8is called `blender/`, which would be a terrible module name and would collide with
9anything else named the same, so the archive renames it to `ifs_fractal/`.
10
11The zip is SELF-CONTAINED: it carries the 46 transform plugins, the kernel
12template, the includes and the templates under `assets/`, because an installed
13add-on sits in Blender's addons directory where the repo is nowhere to be found.
14Run from a checkout the add-on finds no bundled `assets/` and reads the repo's
15live files instead, which is what keeps development honest about the plugins it
16actually compiles.
17
18Longer term the cleaner boundary is baking the generated kernel at build time and
19shipping only that - see the note in ifs_codegen. Shipping the sources keeps the
20add-on able to recompile, which is worth more while the kernel is still moving.
21"""
22
23from __future__ import annotations
24
25import argparse
26import os
27import zipfile
28
29MODULE = "ifs_fractal"
30
31# Everything the add-on needs at runtime. Tests are excluded: they are for the
32# repo, not the installed artifact, and they import a .NET-free corpus that will
33# not be present beside an installed add-on.
34RUNTIME_FILES = (
35 "__init__.py",
36 "ifs_codegen.py",
37 "ifs_layout.py",
38 "ifs_host.py",
39 "ifs_document.py",
40 "ifs_render.py",
41)
42
43
44# Repo-relative asset trees, copied under `assets/` in the archive. The kernel
45# template must come too: the add-on compiles it at load time.
46ASSET_DIRS = (
47 ("Transforms", ".ifstf"),
48 (os.path.join("WpfDisplay", "Library", "Includes"), None),
49 (os.path.join("WpfDisplay", "Library", "Templates"), ".ifsjson"),
50 (os.path.join("IFSEngine", "Rendering", "Shaders"), ".shader"),
51)
52
53
54def build(source_dir: str, out_path: str) -> str:
55 missing = [f for f in RUNTIME_FILES
56 if not os.path.isfile(os.path.join(source_dir, f))]
57 if missing:
58 raise FileNotFoundError(f"missing runtime files: {missing}")
59
60 repo = os.path.dirname(source_dir)
61 count = 0
62 with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as archive:
63 for name in RUNTIME_FILES:
64 archive.write(os.path.join(source_dir, name), f"{MODULE}/{name}")
65 count += 1
66 for rel, suffix in ASSET_DIRS:
67 absolute = os.path.join(repo, rel)
68 if not os.path.isdir(absolute):
69 raise FileNotFoundError(f"asset directory missing: {absolute}")
70 for entry in sorted(os.listdir(absolute)):
71 path = os.path.join(absolute, entry)
72 if not os.path.isfile(path):
73 continue
74 if suffix and not entry.endswith(suffix):
75 continue
76 archive.write(path, f"{MODULE}/assets/{rel}/{entry}")
77 count += 1
78 print(f"packed {count} files")
79 return out_path
80
81
82def main(argv: list[str] | None = None) -> int:
83 here = os.path.dirname(os.path.abspath(__file__))
84 parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
85 parser.add_argument("--out", default=os.path.join(here, "ifs_fractal.zip"))
86 args = parser.parse_args(argv)
87
88 path = build(here, args.out)
89 size = os.path.getsize(path)
90 print(f"wrote {path} ({size / 1024:.1f} KB, {len(RUNTIME_FILES)} files)")
91 print(f"install: Edit > Preferences > Add-ons > Install from Disk -> {path}")
92 return 0
93
94
95if __name__ == "__main__":
96 raise SystemExit(main())
blender/ifs_render.py +29 -10
……@@ -219,7 +219,7 @@ def _build_shaders(width, height, hist_scale):
219219
220220def render(width=512, height=512, dispatches=400, invocation_iters=500,
221221 drain_every=32, hist_scale=DEFAULT_HIST_SCALE, brightness=8.0,
222 out_path="/tmp/ifs_blender.png", input_path=None):
222 out_path=None, input_path=None, image_name="IFS Render"):
223223 gpu.init()
224224
225225 kernel_shader, drain_shader, library = _build_shaders(width, height, hist_scale)
……@@ -314,6 +314,11 @@ def render(width=512, height=512, dispatches=400, invocation_iters=500,
314314 rgb = np.clip(pixels[:, :, :3] * scale, 0.0, 1.0)
315315 out = np.concatenate([rgb, np.ones_like(density)], axis=2)
316316
317 _to_image(out, width, height, image_name)
318 if out_path:
319 _write_png(out, width, height, out_path)
320 print(f"wrote {out_path}")
321
317322 if os.environ.get("IFS_DUMP_DENSITY"):
318323 # Shape without colour: isolates "are the points in the right place" from
319324 # "is the palette being read", which fail identically as a black image.
……@@ -322,18 +327,31 @@ def render(width=512, height=512, dispatches=400, invocation_iters=500,
322327 _write_png(np.concatenate([d, d, d, np.ones_like(d)], axis=2),
323328 width, height, os.environ["IFS_DUMP_DENSITY"])
324329
325 _write_png(out, width, height, out_path)
326 print(f"wrote {out_path}")
327 return out_path
330 return image_name
328331
329332
330def _write_png(rgba, width, height, path):
333def _to_image(rgba, width, height, name):
334 """
335 Writes the result into a Blender Image datablock so the Image Editor shows it.
336
337 Reused rather than recreated when the size matches, so an editor already
338 displaying it keeps showing it across re-renders instead of going blank.
339 """
331340 import bpy
332 name = "ifs_render"
333 if name in bpy.data.images:
334 bpy.data.images.remove(bpy.data.images[name])
335 image = bpy.data.images.new(name, width, height, alpha=True, float_buffer=False)
341 image = bpy.data.images.get(name)
342 if image is not None and tuple(image.size) != (width, height):
343 bpy.data.images.remove(image)
344 image = None
345 if image is None:
346 image = bpy.data.images.new(name, width, height, alpha=True)
336347 image.pixels.foreach_set(rgba.reshape(-1))
348 image.update()
349 return image
350
351
352def _write_png(rgba, width, height, path):
353 import bpy
354 image = _to_image(rgba, width, height, "ifs_render_out")
337355 image.filepath_raw = path
338356 image.file_format = 'PNG'
339357 image.save()
……@@ -343,7 +361,8 @@ def main():
343361 argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
344362 import argparse
345363 parser = argparse.ArgumentParser()
346 parser.add_argument("--out", default="/tmp/ifs_blender.png")
364 parser.add_argument("--out", default="/tmp/ifs_blender.png",
365 help="PNG to write; the result is always left in an Image datablock")
347366 parser.add_argument("--size", type=int, default=512)
348367 parser.add_argument("--dispatches", type=int, default=400)
349368 parser.add_argument("--input", default=None, help="an .ifsjson document")