meltygui 0.1.0__py3-none-any.whl

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.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1860 @@
1
+ """Voxel presentation: volume passes, camera interaction and in-scene labels."""
2
+ from meltygui.core.graphics.tensor_core import _voxels_cleanup
3
+ from meltygui.hdr_color import pack_color
4
+ from meltygui.core.graphics.gl_state import GLState
5
+ from meltygui.model.lut_model import Lut, LutPalette
6
+ from meltygui.model.tensor_model import TensorDim
7
+ from meltygui.model.tensor_model import TensorDims
8
+ from meltygui.core.rendering.modes import Modes
9
+ from meltygui.core.core_render import render_func
10
+ from meltygui.core.rendering.shaped import Shaped
11
+ from meltygui.core.runtime.toggles import SwooshMode
12
+ from meltygui.core.runtime.toggles import Toggles
13
+ from meltygui.view.header_view import draw_header
14
+ import OpenGL.GL as gl
15
+ import math
16
+ import meltygui_imgui as imgui
17
+ import numpy as np
18
+ import ctypes
19
+ from meltygui.core.styling.fonts import Font
20
+ from meltygui.core.graphics.text_texture import bake_texts
21
+ from meltygui.model.camera_model import basis as _cam_basis
22
+ from meltygui.core.graphics.shader_func import shader_func
23
+ from meltygui.core.graphics.tensor_core import source_identity
24
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
25
+ from meltygui.model.texture_model import _cached_volume_texture, _upload_cuda_image
26
+ from meltygui.state.voxel_state import VoxelState
27
+ from meltygui.view.texture_view import image_blit_pass
28
+ from meltygui.view.tensor_view import _describe_tensor, _view_size, _draw_image_notice
29
+ from meltygui.view.tensor_view import _draw_tensor_meta, _draw_voxel_error, _draw_slice_sliders, _tick_values
30
+
31
+
32
+ @render_func(is_default_for=("GLTexture",
33
+ # 3-D+ tensors by shape; 1-D/2-D route to
34
+ # draw_line_graph. The bare "Tensor" name stays
35
+ # as the fallback for 0-D / anything unmatched
36
+ # (the error card is the right place for those).
37
+ Shaped("Tensor", (None, None, None, ...)),
38
+ Shaped("ndarray", (None, None, None, ...)),
39
+ "Tensor", "ndarray"),
40
+ show_bg=True, selectable=True,
41
+ auto_resize=False, min_width=269, with_header=draw_header,
42
+ bg_offset=0, min_height=293, disable_scroll=True, use_cache=True,
43
+ on_cleanup=_voxels_cleanup)
44
+ def draw_voxels(input_value: object = None, gl_state: GLState = None, selectable=False,
45
+ draw_state=None,
46
+ # ── camera + shading: cam_* names dodge the legacy DrawState
47
+ # zoom/brightness/contrast fields (name-colliding params are
48
+ # excluded from auto-state). Gestures/panel write
49
+ # draw_state.<name>; diverged values persist. ──
50
+ tilt=0.283, spin=0.724, roll=0.0, cam_zoom=3.4,
51
+ # [tint=(0.084, 0.472, 0.148, 1.0)]
52
+ pan_x=0.0, pan_y=0.0, pan_z=0.0, ortho=False,
53
+ cam_brightness=1.332, cam_contrast=1.0,
54
+ # density = the old densityScale (haze gain over the opacity
55
+ # gate); threshold = the old opacityThreshold (higher → lower
56
+ # gate → more opaque)
57
+ density=0.7, threshold=0.297, centered=False,
58
+ nearest=True, lut=Lut("jet"), step_size=0.0005, max_steps=4096,
59
+ # ── shadow catcher: the invisible plane the box rests on -
60
+ # it renders nothing but the volume's cast shadow (one-sided:
61
+ # no shadow from below). shadow_opacity scales how dark the
62
+ # caught shadow composites; shadow_softness scales the
63
+ # screen-space penumbra blur (radius grows with occluder
64
+ # height) - 0 = hard edge, bigger = wider penumbra. ──
65
+ draw_plane=True, shadow_opacity=1.0, shadow_softness=0.15,
66
+ # ── lighting: draw_shading lights the floor (per-s., the
67
+ # raymarched cast shadow) and the volume (gradient-normal
68
+ # Lambert); self_shading adds the per-sample transmittance
69
+ # march inside the volume - the expensive tier. ambient_light
70
+ # is the shadow floor: how much light survives everywhere.
71
+ # shading_strength scales how much the volume's cast normal
72
+ # may darken its LUT color (0 = shading off, plane still
73
+ # catches). ──
74
+ draw_shading=True, self_shading=True,
75
+ light_pos=(50.0, -50.0, 200.0), light_tint=(1.0, 1.0, 1.0),
76
+ light_brightness=1.622, ambient_light=0.3, shading_strength=0.7,
77
+ # ── axis mapping: dims by index OR NAME. The first three dims
78
+ # by default; None still means "derive" (last three → z/y/x)
79
+ # for anything that clears one. ──
80
+ dim_names=("layer", "batch", "token", "feature"),
81
+ x_dim=TensorDim(0), y_dim=TensorDim(2), z_dim=TensorDim(2),
82
+ slices=(),
83
+ mean_dims=TensorDims(()), sort_dim=TensorDim(-1),
84
+ normalize=False, nf_on=False, nf_chop=TensorDim(-1),
85
+ nf_along=TensorDim(-1), nf_chunk=128,
86
+ # ── volume furniture (screen px) ──
87
+ name_size=17.0, name_padding=30.1, name_opacity=1.1,
88
+ num_size=17.1, num_padding=5.5, num_opacity=0.8,
89
+ num_spacing=1.0, num_angle=0.0, z_offset=1,
90
+ middle_mouse_drag=None, double_right_mouse_drag=None,
91
+ scroll_y_changed=None, space_mouse_changed=None,
92
+ left_mouse_double_clicked=None,
93
+ kp_7_pressed=None, kp_1_pressed=None, kp_3_pressed=None,
94
+ kp_5_pressed=None, slash_pressed=None, kp_divide_pressed=None,
95
+ kp_decimal_pressed=None, font_manager=None,
96
+ luts: LutPalette = None, voxel_state: VoxelState = None,
97
+ keyboard_available=True, pointer_buttons_down=False, **kwargs):
98
+ """Render voxels using CUDA for CUDA tensors and OpenGL for other inputs."""
99
+ parameters = locals()
100
+ extra = parameters.pop("kwargs")
101
+ return _draw_voxels(**(extra | parameters | {"backend": "auto"}))
102
+
103
+
104
+ @render_func(show_bg=True, selectable=True,
105
+ auto_resize=False, min_width=269, with_header=draw_header,
106
+ bg_offset=0, min_height=293, disable_scroll=True, use_cache=True,
107
+ on_cleanup=_voxels_cleanup)
108
+ def draw_voxels_opengl(input_value: object = None, gl_state: GLState = None, selectable=False,
109
+ draw_state=None,
110
+ tilt=0.283, spin=0.724, roll=0.0, cam_zoom=3.4,
111
+ pan_x=0.0, pan_y=0.0, pan_z=0.0, ortho=False,
112
+ cam_brightness=1.332, cam_contrast=1.0,
113
+ density=0.7, threshold=0.297, centered=False,
114
+ nearest=True, lut=Lut("jet"), step_size=0.0005, max_steps=4096,
115
+ draw_plane=True, shadow_opacity=1.0, shadow_softness=0.15,
116
+ draw_shading=True, self_shading=True,
117
+ light_pos=(50.0, -50.0, 200.0), light_tint=(1.0, 1.0, 1.0),
118
+ light_brightness=1.622, ambient_light=0.3, shading_strength=0.7,
119
+ dim_names=("layer", "batch", "token", "feature"),
120
+ x_dim=TensorDim(0), y_dim=TensorDim(2), z_dim=TensorDim(2),
121
+ slices=(),
122
+ mean_dims=TensorDims(()), sort_dim=TensorDim(-1),
123
+ normalize=False, nf_on=False, nf_chop=TensorDim(-1),
124
+ nf_along=TensorDim(-1), nf_chunk=128,
125
+ name_size=17.0, name_padding=30.1, name_opacity=1.1,
126
+ num_size=17.1, num_padding=5.5, num_opacity=0.8,
127
+ num_spacing=1.0, num_angle=0.0, z_offset=1,
128
+ middle_mouse_drag=None, double_right_mouse_drag=None,
129
+ scroll_y_changed=None, space_mouse_changed=None,
130
+ left_mouse_double_clicked=None,
131
+ kp_7_pressed=None, kp_1_pressed=None, kp_3_pressed=None,
132
+ kp_5_pressed=None, slash_pressed=None, kp_divide_pressed=None,
133
+ kp_decimal_pressed=None, font_manager=None,
134
+ luts: LutPalette = None, voxel_state: VoxelState = None,
135
+ keyboard_available=True, pointer_buttons_down=False, **kwargs):
136
+ """Raymarch tensors, NumPy arrays or GLTexture values with OpenGL."""
137
+ parameters = locals()
138
+ extra = parameters.pop("kwargs")
139
+ return _draw_voxels(**(extra | parameters | {"backend": "opengl"}))
140
+
141
+
142
+ @render_func(show_bg=True, selectable=True,
143
+ auto_resize=False, min_width=269, with_header=draw_header,
144
+ bg_offset=0, min_height=293, disable_scroll=True, use_cache=True,
145
+ on_cleanup=_voxels_cleanup)
146
+ def draw_voxels_cuda(input_value: object = None, gl_state: GLState = None, selectable=False,
147
+ draw_state=None,
148
+ tilt=0.283, spin=0.724, roll=0.0, cam_zoom=3.4,
149
+ pan_x=0.0, pan_y=0.0, pan_z=0.0, ortho=False,
150
+ cam_brightness=1.332, cam_contrast=1.0,
151
+ density=0.7, threshold=0.297, centered=False,
152
+ nearest=True, lut=Lut("jet"), step_size=0.0005, max_steps=4096,
153
+ draw_plane=True, shadow_opacity=1.0, shadow_softness=0.15,
154
+ draw_shading=True, self_shading=True,
155
+ light_pos=(50.0, -50.0, 200.0), light_tint=(1.0, 1.0, 1.0),
156
+ light_brightness=1.622, ambient_light=0.3, shading_strength=0.7,
157
+ dim_names=("layer", "batch", "token", "feature"),
158
+ x_dim=TensorDim(0), y_dim=TensorDim(2), z_dim=TensorDim(2),
159
+ slices=(),
160
+ mean_dims=TensorDims(()), sort_dim=TensorDim(-1),
161
+ normalize=False, nf_on=False, nf_chop=TensorDim(-1),
162
+ nf_along=TensorDim(-1), nf_chunk=128,
163
+ name_size=17.0, name_padding=30.1, name_opacity=1.1,
164
+ num_size=17.1, num_padding=5.5, num_opacity=0.8,
165
+ num_spacing=1.0, num_angle=0.0, z_offset=1,
166
+ middle_mouse_drag=None, double_right_mouse_drag=None,
167
+ scroll_y_changed=None, space_mouse_changed=None,
168
+ left_mouse_double_clicked=None,
169
+ kp_7_pressed=None, kp_1_pressed=None, kp_3_pressed=None,
170
+ kp_5_pressed=None, slash_pressed=None, kp_divide_pressed=None,
171
+ kp_decimal_pressed=None, font_manager=None,
172
+ luts: LutPalette = None, voxel_state: VoxelState = None,
173
+ keyboard_available=True, pointer_buttons_down=False, **kwargs):
174
+ """Raymarch a CUDA tensor in place on its own GPU."""
175
+ parameters = locals()
176
+ extra = parameters.pop("kwargs")
177
+ return _draw_voxels(**(extra | parameters | {"backend": "cuda"}))
178
+
179
+
180
+ def _draw_voxels(input_value: object = None, gl_state: GLState = None, selectable=False,
181
+ draw_state=None,
182
+ # ── camera + shading: cam_* names dodge the legacy DrawState
183
+ # zoom/brightness/contrast fields (name-colliding params are
184
+ # excluded from auto-state). Gestures/panel write
185
+ # draw_state.<name>; diverged values persist. ──
186
+ tilt=0.283, spin=0.724, roll=0.0, cam_zoom=3.4,
187
+ # [tint=(0.084, 0.472, 0.148, 1.0)]
188
+ pan_x=0.0, pan_y=0.0, pan_z=0.0, ortho=False,
189
+ cam_brightness=1.332, cam_contrast=1.0,
190
+ # density = the old densityScale (haze gain over the opacity
191
+ # gate); threshold = the old opacityThreshold (higher → lower
192
+ # gate → more opaque)
193
+ density=0.7, threshold=0.297, centered=False,
194
+ nearest=True, lut=Lut("jet"), step_size=0.0005, max_steps=4096,
195
+ # ── shadow catcher: the invisible plane the box rests on -
196
+ # it renders nothing but the volume's cast shadow (one-sided:
197
+ # no shadow from below). shadow_opacity scales how dark the
198
+ # caught shadow composites; shadow_softness scales the
199
+ # screen-space penumbra blur (radius grows with occluder
200
+ # height) - 0 = hard edge, bigger = wider penumbra. ──
201
+ draw_plane=True, shadow_opacity=1.0, shadow_softness=0.15,
202
+ # ── lighting: draw_shading lights the floor (per-s., the
203
+ # raymarched cast shadow) and the volume (gradient-normal
204
+ # Lambert); self_shading adds the per-sample transmittance
205
+ # march inside the volume - the expensive tier. ambient_light
206
+ # is the shadow floor: how much light survives everywhere.
207
+ # shading_strength scales how much the volume's cast normal
208
+ # may darken its LUT color (0 = shading off, plane still
209
+ # catches). ──
210
+ draw_shading=True, self_shading=True,
211
+ light_pos=(50.0, -50.0, 200.0), light_tint=(1.0, 1.0, 1.0),
212
+ light_brightness=1.622, ambient_light=0.3, shading_strength=0.7,
213
+ # ── axis mapping: dims by index OR NAME. The first three dims
214
+ # by default; None still means "derive" (last three → z/y/x)
215
+ # for anything that clears one. ──
216
+ dim_names=("layer", "batch", "token", "feature"),
217
+ x_dim=TensorDim(0), y_dim=TensorDim(2), z_dim=TensorDim(2),
218
+ slices=(),
219
+ mean_dims=TensorDims(()), sort_dim=TensorDim(-1),
220
+ normalize=False, nf_on=False, nf_chop=TensorDim(-1),
221
+ nf_along=TensorDim(-1), nf_chunk=128,
222
+ # ── volume furniture (screen px) ──
223
+ name_size=17.0, name_padding=30.1, name_opacity=1.1,
224
+ num_size=17.1, num_padding=5.5, num_opacity=0.8,
225
+ num_spacing=1.0, num_angle=0.0, z_offset=1,
226
+ middle_mouse_drag=None, double_right_mouse_drag=None,
227
+ scroll_y_changed=None, space_mouse_changed=None,
228
+ left_mouse_double_clicked=None,
229
+ kp_7_pressed=None, kp_1_pressed=None, kp_3_pressed=None,
230
+ kp_5_pressed=None, slash_pressed=None, kp_divide_pressed=None,
231
+ kp_decimal_pressed=None, font_manager=None,
232
+ luts: LutPalette = None, voxel_state: VoxelState = None,
233
+ keyboard_available=True, pointer_buttons_down=False, backend="auto", **kwargs):
234
+ """The voxel renderer — owner of every render and mapping decision.
235
+ Input is a tensor/ndarray (sliced + uploaded HERE, re-keyed by gl_state
236
+ deps on source identity/_version/mapping) or an already-uploaded
237
+ GLTexture (rendered as-is). Tensor METADATA — full shape, dim count —
238
+ rides the uploaded buffer; EVERYTHING else is a parameter on this
239
+ signature (auto draw_state params: gestures and the controls panel
240
+ write draw_state.<name>, only diverged values persist/serialize)."""
241
+ from meltygui.core.graphics.gl_state import GLTexture
242
+ from meltygui.core.graphics.gl_state import gl_limits
243
+ from meltygui.core.graphics.gl_state import texture3d_fit
244
+ from meltygui.model.camera_model import apply_space_mouse
245
+ from meltygui.model.tensor_model import CudaVolumeView
246
+ from meltygui.model.tensor_model import _AXIS_POS
247
+ from meltygui.model.tensor_model import _clean_dim_name
248
+ from meltygui.model.tensor_model import _resolve_dim
249
+ from meltygui.model.tensor_model import _volume_scale
250
+ from meltygui.model.tensor_model import auto_neural_flow
251
+ from meltygui.model.tensor_model import slice_volume
252
+ from meltygui.model.tensor_model import slice_volume_view
253
+ from meltygui.core.windowing.glfw_utils import request_render
254
+ from meltygui.core.rendering.render_dispatch import draw_any
255
+
256
+ src = input_value
257
+ if src is None:
258
+ # Between a live value being released (a new run's first publish
259
+ # drops the previous generation) and the new data arriving, the
260
+ # window renders with no value. Hold the LAST image - the FBO
261
+ # survives that release - and keep the slice sliders up (their
262
+ # geometry comes from the metadata still on the cached volume
263
+ # entry) instead of flashing an error card; a window that never
264
+ # rendered shows nothing.
265
+ fb = gl_state.peek("target")
266
+ if fb is not None:
267
+ dim_names = tuple(_clean_dim_name(x, i) for i, x in enumerate(dim_names or ()))
268
+ slices = tuple(int(v) for v in (slices or ()))
269
+ mean_dims = tuple(int(v) for v in (mean_dims or ()))
270
+ meta = None
271
+ for key in ("volume_cuda", "volume", "cuda_view"):
272
+ rec = gl_state.peek(key)
273
+ if rec is not None:
274
+ meta = getattr(rec, "texture", rec)
275
+ break
276
+ source_shape = tuple(getattr(meta, "source_shape", ()) or ())
277
+ mapping = getattr(meta, "mapping", None)
278
+ # the marker's dim_names merge skips a None value; use the names
279
+ # the last valid frame stamped
280
+ dim_names = tuple(getattr(meta, "dim_names", None) or dim_names)
281
+ slider_dims = []
282
+ if mapping is not None and len(source_shape) > 3:
283
+ slider_dims = [d for d in range(len(source_shape))
284
+ if d not in mapping and d not in mean_dims
285
+ and source_shape[d] > 1]
286
+ width, height = _view_size(draw_state)
287
+ if slider_dims:
288
+ height = max(100, height - int(imgui.get_frame_height_with_spacing())
289
+ * len(slider_dims))
290
+ img_pos = imgui.get_cursor_screen_pos()
291
+ imgui.image(fb.texture_id, width, height, uv0=(0, 1), uv1=(1, 0))
292
+ # the 2-D axis outline is drawn over the image each frame (not
293
+ # rendered into the FBO) - recompute it from the last volume's
294
+ # extents + the current camera so it doesn't blink either
295
+ shape = tuple(getattr(meta, "shape", ()) or ())
296
+ if len(shape) == 3:
297
+ try:
298
+ edges = _axis_edges(tilt, spin, cam_zoom, width / height, width, height,
299
+ scale=_volume_scale(shape),
300
+ pan=(pan_x, pan_y, pan_z), ortho=ortho, roll=roll)
301
+ if edges:
302
+ _draw_axis_lines(imgui.get_window_draw_list(), img_pos, edges)
303
+ except Exception:
304
+ pass
305
+ _draw_slice_sliders(draw_state, slider_dims, dim_names, slices, source_shape, width)
306
+ return False, None
307
+ # A 1-D texture is a LUT, not a volume - don't try to raymarch it.
308
+ if getattr(src, "target", None) == int(gl.GL_TEXTURE_1D):
309
+ imgui.text(f"{src!r} — a LUT, not a volume")
310
+ return False, None
311
+
312
+ dim_names = tuple(_clean_dim_name(x, i) for i, x in enumerate(dim_names or ()))
313
+ slices = tuple(int(v) for v in (slices or ()))
314
+ mean_dims = tuple(int(v) for v in (mean_dims or ()))
315
+
316
+ # ── source → display volume → GPU, parameter-driven and stateless:
317
+ # slice_volume is a pure function of the params, the upload re-runs
318
+ # exactly when its deps change, and tensor metadata rides the buffer.
319
+ if backend == "cuda" and not bool(getattr(src, "is_cuda", False)):
320
+ _draw_voxel_error(draw_state, "CUDA voxel rendering requires a CUDA tensor.")
321
+ gl_state.drop("volume"); gl_state.drop("volume_cuda"); gl_state.drop("cuda_view")
322
+ return False, input_value
323
+ if isinstance(src, GLTexture):
324
+ tex, mapping = src, None
325
+ source_shape = tuple(getattr(src, "source_shape", src.shape))
326
+ else:
327
+ try:
328
+ import torch
329
+ except ImportError:
330
+ _draw_voxel_error(draw_state, "Voxel rendering of arrays and tensors requires torch:\n"
331
+ "pip install meltygui[tensor]")
332
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
333
+ return False, input_value
334
+ try:
335
+ t = src if isinstance(src, torch.Tensor) else torch.from_numpy(np.asarray(src))
336
+ except (TypeError, ValueError, RuntimeError) as e:
337
+ _draw_voxel_error(draw_state, f"{type(src).__name__} is not tensor-shaped:\n{e}")
338
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
339
+ return False, None
340
+ # ── preflight: slicing + upload can fail on BAD DATA (odd dtypes,
341
+ # empty tensors) or on the DRIVER (an extent past
342
+ # GL_MAX_3D_TEXTURE_SIZE, a volume larger than VRAM). Decide here,
343
+ # before any GL interaction: over-limit extents CLAMP to the
344
+ # displayable prefix (with a notice over the image), anything
345
+ # unrecoverable shows an error card in place of the 3-D view. The
346
+ # error path also flushes stale textures so a bad frame never
347
+ # keeps a previous volume alive under the message. ─────────────
348
+ # ── auto neural flow: if nf OFF, a displayed axis longer than the
349
+ # readability cap (Toggles.Voxels.auto_flow_extent) or the GL limit
350
+ # is wrapped HERE - effective nf_* locals for this render only (the
351
+ # user's params stay untouched; the labels below use the effective
352
+ # values and show e.g. "vocab % 180" or "batch - vocab"). ───────────
353
+ nf_pad = False
354
+ # Explicit OpenGL selection uploads the sliced tensor to a 3-D
355
+ # texture, including CUDA tensors. Automatic selection keeps CUDA
356
+ # sources in place; the CUDA renderer never silently falls back.
357
+ use_cuda = backend != "opengl" and bool(getattr(t, "is_cuda", False))
358
+ if use_cuda and not _cuda_march_ready():
359
+ _draw_voxel_error(draw_state, "CUDA tensor rendering requires meltygui[tensor]; "
360
+ "the tensor was not copied to the CPU.")
361
+ return False, None
362
+ if not nf_on:
363
+ _cap = int(Toggles.Voxels.auto_flow_extent)
364
+ if use_cuda:
365
+ # no 3-D texture → no GL extent limit; only the readability cap
366
+ _cap = _cap if _cap > 0 else 0
367
+ else:
368
+ _gl_max = int(gl_limits()["max_3d"])
369
+ _cap = min(_cap, _gl_max) if _cap > 0 else _gl_max
370
+ auto = auto_neural_flow(t.shape, dim_names, x_dim, y_dim, z_dim, _cap)
371
+ if auto is not None:
372
+ nf_on, nf_pad = True, True
373
+ nf_chop, nf_along, nf_chunk = (TensorDim(auto[0]),
374
+ TensorDim(auto[1]), auto[2])
375
+ # ── cache gate BEFORE any tensor work: slice_volume is pure but not
376
+ # free - on a multi-GB tensor its type coercion / permute-contiguous
377
+ # / neural-flow repack / normalize min-max are whole-tensor GPU
378
+ # passes, and running them every frame (the upload was already
379
+ # version-gated, the slice that feeds it was not) pinned an orbit
380
+ # at ~18 fps with the raymarcher all the way down. The key is
381
+ # the params that decide the volume (the effective nf_* after
382
+ # auto-flow); the volume-derived facts (mapping, source_shape,
383
+ # clamp_note) ride the cached texture. ─────────────────────────
384
+ vol_key = (source_identity(src), dim_names,
385
+ str(x_dim), str(y_dim), str(z_dim), slices, mean_dims,
386
+ int(sort_dim), bool(normalize), bool(nf_on), str(nf_chop),
387
+ str(nf_along), int(nf_chunk), nf_pad, use_cuda)
388
+ tex = _cached_volume_texture(gl_state, vol_key)
389
+ if tex is not None:
390
+ mapping, source_shape = tex.mapping, tex.source_shape
391
+ vol = None
392
+ if not isinstance(src, GLTexture) and tex is None and use_cuda:
393
+ try:
394
+ cv = slice_volume_view(
395
+ t, dim_names, x_dim, y_dim, z_dim, slices, mean_dims,
396
+ sort_dim, normalize, nf_on, nf_chop, nf_along, nf_chunk,
397
+ nf_pad=nf_pad)
398
+ except (ValueError, TypeError, RuntimeError, IndexError) as e:
399
+ _draw_voxel_error(draw_state, f"can't build a volume view from "
400
+ f"{_describe_tensor(t)}:\n{e}")
401
+ gl_state.drop("cuda_view")
402
+ return False, None
403
+ cv._vol_key = vol_key
404
+ cv.dim_names = dim_names
405
+ # The view is the cached "volume": gl_state holds it by the same key
406
+ # discipline as the textures (no GL - nothing to delete). The GL
407
+ # volume (possibly GBs of display-GPU VRAM) is released on the
408
+ # switch, and vice versa below.
409
+ gl_state.drop("cuda_view")
410
+ gl_state.get("cuda_view", lambda: cv, deps=vol_key)
411
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
412
+ tex, mapping, source_shape = cv, cv.mapping, cv.source_shape
413
+ if not isinstance(src, GLTexture) and tex is None:
414
+ gl_state.drop("cuda_view")
415
+ try:
416
+ vol, mapping, source_shape = slice_volume(
417
+ t, dim_names, x_dim, y_dim, z_dim, slices, mean_dims,
418
+ sort_dim, normalize, nf_on, nf_chop, nf_along, nf_chunk,
419
+ nf_pad=nf_pad)
420
+ except (ValueError, TypeError, RuntimeError, IndexError) as e:
421
+ _draw_voxel_error(draw_state, f"can't build a volume from "
422
+ f"{_describe_tensor(t)}:\n{e}")
423
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
424
+ return False, None
425
+ # Extents only here (max_bytes=inf): the VRAM budget is judged by
426
+ # the upload on a cache MISS (texture3d / tensor_to_texture create),
427
+ # since it's measured against FREE memory and a cached volume's GPU
428
+ # allocation must not fail its next frame. That refusal surfaces
429
+ # as the "GPU upload failed" card below.
430
+ clamped_shape, problems = texture3d_fit(vol.shape, vol.element_size(),
431
+ max_bytes=float("inf"))
432
+ if any("GL_MAX_3D_TEXTURE_SIZE" not in p for p in problems):
433
+ _draw_voxel_error(draw_state, f"{_describe_tensor(t)} → volume "
434
+ f"{tuple(int(s) for s in vol.shape)}:\n"
435
+ + "\n".join(problems))
436
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
437
+ return False, None
438
+ clamp_note = None
439
+ if problems:
440
+ # Display the leading max-size block of each over-limit axis.
441
+ d3, h3, w3 = clamped_shape
442
+ vol = vol[:d3, :h3, :w3].contiguous()
443
+ clamp_note = "clamped: " + "; ".join(problems)
444
+ version = vol_key + (mapping, clamped_shape)
445
+ try:
446
+ if vol.is_cuda:
447
+ from meltygui.model.cuda_texture_model import tensor_to_texture
448
+ tex = tensor_to_texture(gl_state, "volume_cuda", vol,
449
+ version=version)
450
+ if tex is None:
451
+ tex = gl_state.texture3d("volume", vol.cpu().numpy(), version=version)
452
+ gl_state.drop("volume_cuda")
453
+ else:
454
+ gl_state.drop("volume")
455
+ except Exception as e:
456
+ # The driver refused something the pre-flight didn't predict
457
+ # (out of memory, unsupported format...). Show it, don't crash
458
+ # the render loop; the deps still ensure we retry only when the
459
+ # source or mapping change.
460
+ _draw_voxel_error(draw_state, f"GPU upload failed for volume "
461
+ f"{tuple(int(s) for s in vol.shape)} "
462
+ f"({vol.dtype}):\n{e}")
463
+ gl_state.drop("volume"); gl_state.drop("volume_cuda")
464
+ return False, None
465
+ tex.source_shape = source_shape # tensor metadata on the buffer
466
+ tex.source_ndim = len(source_shape)
467
+ tex.clamp_note = clamp_note
468
+ tex.mapping = mapping
469
+ tex.dim_names = dim_names
470
+ tex._vol_key = vol_key # the gate above uses this
471
+ vol = None # don't hold the temp volume
472
+
473
+ # Edge labels - the mapped dim's name (+ neural-flow decoration) and its
474
+ # DISPLAYED size, recomputed per frame from the params.
475
+ if mapping is not None:
476
+ zd, yd, xd = mapping
477
+ n = len(source_shape)
478
+ chop_d = _resolve_dim(dim_names, nf_chop, n)
479
+ along_d = _resolve_dim(dim_names, nf_along, n)
480
+ chop_d = xd if chop_d is None else chop_d
481
+ along_d = zd if along_d is None else along_d
482
+ display = []
483
+ for axis, dim in (("x", xd), ("y", yd), ("z", zd)):
484
+ label = dim_names[dim] if dim < len(dim_names) else f"dim{dim}"
485
+ if nf_on and dim == chop_d:
486
+ label = f"{label} % {int(nf_chunk)}" # chopped into chunks
487
+ elif nf_on and dim == along_d:
488
+ chop_name = (dim_names[chop_d]
489
+ if chop_d < len(dim_names) else f"dim{chop_d}")
490
+ label = f"{label} · {chop_name}" # along the blocks
491
+ display.append((label, int(tex.shape[_AXIS_POS[axis]])))
492
+ axis_display = tuple(display)
493
+ else:
494
+ d3, h3, w3 = (int(s) for s in tex.shape)
495
+ axis_display = ((dim_names[2] if len(dim_names) > 2 else "x", w3),
496
+ (dim_names[1] if len(dim_names) > 1 else "y", h3),
497
+ (dim_names[0] if dim_names else "z", d3))
498
+
499
+ # ── slice sliders: >3-dim tensors get one slider per UNMAPPED dim (not
500
+ # displayed, not averaged, extent > 1) along the bottom of the view to
501
+ # choose which slice is pinned. GLTexture inputs arrive pre-sliced
502
+ # (mapping is None) - nothing to scrub. ─────────────────────────────
503
+ slider_dims = []
504
+ if mapping is not None and len(source_shape) > 3:
505
+ slider_dims = [d for d in range(len(source_shape))
506
+ if d not in mapping and d not in mean_dims
507
+ and source_shape[d] > 1]
508
+
509
+ # Size from the OWNING WINDOW, not this view's own draw() - a nested
510
+ # view's height derives from what it rendered last frame (self-referential),
511
+ # while the window's height is the user-dragged size. Reserve room for the
512
+ # header + a line below the image.
513
+ # The owning window IS this draw_state when draw_voxels is itself a window
514
+ # (mode=WINDOW / closable); only fall back to the enclosing window for the
515
+ # non-window child case. Using draw_state.parent_window for a closable voxel
516
+ # grabbed an ANCESTOR (e.g. live_view_forward) that doesn't move with the
517
+ # voxel window, so the controls panel - parented to `win` below - followed
518
+ # the ancestor and stayed put while the voxel window was dragged.
519
+ win = draw_state if draw_state.closable else (draw_state.parent_window or draw_state)
520
+ width, height = _view_size(draw_state)
521
+ if slider_dims:
522
+ # The sliders live INSIDE the view's box - give them their rows by
523
+ # shrinking the image, not by growing past the window.
524
+ height = max(100, height - int(imgui.get_frame_height_with_spacing())
525
+ * len(slider_dims))
526
+
527
+ # ── in-flight locate values: a locate_* write to a SLOW source (e.g. a
528
+ # `# [cam_brightness=...]` comment) is deferred during drags and lands
529
+ # multi-frame after; until then the injected kwarg is stale. Re-read any
530
+ # camera param with a value set through locate_* (which also clears the
531
+ # entry once the trip lands) so drags accumulate off the latest state.
532
+ # _sa_precise rides the same way: a low-precision source has a 4dp
533
+ # rounding, so locate_* serves the full-precision overlay over it. ──
534
+ _pending = getattr(draw_state, "_sa_pending", None) or {}
535
+ _precise = getattr(draw_state, "_sa_precise", None) or {}
536
+ _in_flight = _pending.keys() | _precise.keys()
537
+ if _in_flight:
538
+ def _fly(n, cur):
539
+ if n not in _in_flight:
540
+ return cur
541
+ v = getattr(draw_state, "locate_" + n)
542
+ return cur if v is None else v
543
+ tilt, spin, cam_zoom = _fly("tilt", tilt), _fly("spin", spin), _fly("cam_zoom", cam_zoom)
544
+ roll = _fly("roll", roll)
545
+ pan_x, pan_y, pan_z = _fly("pan_x", pan_x), _fly("pan_y", pan_y), _fly("pan_z", pan_z)
546
+ cam_brightness = _fly("cam_brightness", cam_brightness)
547
+ cam_contrast = _fly("cam_contrast", cam_contrast)
548
+ ortho = _fly("ortho", ortho)
549
+ # EVERY display-affecting panel param rides the same gate, not just
550
+ # the camera: a panel edit whose driving source is slow (an override
551
+ # comment's parse->copy->hotkey trip) otherwise renders the STALE
552
+ # injected kwarg until the trip lands - "changes not always
553
+ # reflected", especially visible on the cuda path where transfer params
554
+ # also key the mip/floor bakes.
555
+ density, threshold = _fly("density", density), _fly("threshold", threshold)
556
+ step_size, max_steps = _fly("step_size", step_size), _fly("max_steps", max_steps)
557
+ nearest, centered = _fly("nearest", nearest), _fly("centered", centered)
558
+ draw_plane = _fly("draw_plane", draw_plane)
559
+ shadow_opacity = _fly("shadow_opacity", shadow_opacity)
560
+ shadow_softness = _fly("shadow_softness", shadow_softness)
561
+ draw_shading = _fly("draw_shading", draw_shading)
562
+ self_shading = _fly("self_shading", self_shading)
563
+ light_pos, light_tint = _fly("light_pos", light_pos), _fly("light_tint", light_tint)
564
+ light_brightness = _fly("light_brightness", light_brightness)
565
+ ambient_light = _fly("ambient_light", ambient_light)
566
+ shading_strength = _fly("shading_strength", shading_strength)
567
+ # # (Data-shaping params - slices/dims/nf/scale/normalize - are consumed
568
+ # # ABOVE this gate and they can't be re-read here, but the pump below
569
+ # # re-renders until the trip lands and the new tex_key rebuilds.)
570
+ # # And keep re-rendering until the slow write LANDS (locate_* clears
571
+ # # the entry): the landing itself doesn't invalidate this frame, so
572
+ # # without the pump the final value never rebuilds.
573
+ # draw_state.invalidate()
574
+ # request_render()
575
+
576
+ # ── gestures → draw_state params (auto-state: the caller diverges the
577
+ # param so it persists; events are hover-routed wrapper kwargs) ──────
578
+ if middle_mouse_drag is not None:
579
+ # Ortable chirality, latched per GESTURE: upside down (cos(tilt)<0,
580
+ # world-up pointing down the screen) a rightward move must spin the
581
+ # other way to keep tracking the cursor. Latching at drag start keeps
582
+ # the direction stable when a drag tilts across the pole mid-gesture;
583
+ # the latch clears on release so the next drag re-reads orientation.
584
+ spin_sign = getattr(draw_state, "_orbit_spin_sign", None)
585
+ if spin_sign is None:
586
+ spin_sign = -1.0 if math.cos(tilt) < 0.0 else 1.0
587
+ draw_state._orbit_spin_sign = spin_sign
588
+ if middle_mouse_drag.shift:
589
+ # Blender-style shift-d = pan: move the orbit target so the
590
+ # content tracks the cursor 1:1 at the target plane (world units
591
+ # per pixel at current cam_zoom, focal 1.7 - matches the ray gen).
592
+ wpp = 2.0 * cam_zoom / (1.7 * height)
593
+ st, ct = math.sin(tilt), math.cos(tilt)
594
+ cs, ss = math.cos(spin), math.sin(spin)
595
+ dx, dy = middle_mouse_drag.dx, middle_mouse_drag.dy
596
+ pan_x += (ss * dx - cs * st * dy) * wpp
597
+ pan_y += (-cs * dx - ss * st * dy) * wpp
598
+ pan_z += ct * dy * wpp
599
+ draw_state.locate_pan_x = pan_x
600
+ draw_state.locate_pan_y = pan_y
601
+ draw_state.locate_pan_z = pan_z
602
+ elif middle_mouse_drag.ctrl:
603
+ # the old viewer's ctrl-drag: vertical = dolly zoom, horizontal
604
+ # still orbits.
605
+ cam_zoom = min(137.6, max(0.0, cam_zoom * math.exp(0.005 * middle_mouse_drag.dy)))
606
+ spin -= middle_mouse_drag.dx * 0.008 * spin_sign
607
+ draw_state.locate_cam_zoom = cam_zoom
608
+ draw_state.locate_spin = spin
609
+ elif Toggles.Voxels.mouse_navigation == "trackball":
610
+ # Mouse drag as a rotation vector in VIEW space (dy pitches about
611
+ # screen-right, dx yaws about screen-up), through the same geometry
612
+ # as the 3D mouse's trackball - roll is the third angle.
613
+ tilt, spin, roll, _, _ = apply_space_mouse(
614
+ (0.0, 0.0, 0.0, middle_mouse_drag.dy * 0.008,
615
+ middle_mouse_drag.dx * 0.008, 0.0),
616
+ tilt, spin, roll, cam_zoom, (pan_x, pan_y, pan_z),
617
+ navigation="trackball", orbit_sensitivity=1.0,
618
+ pan_sensitivity=0.0, zoom_sensitivity=0.0)
619
+ draw_state.locate_spin = spin
620
+ draw_state.locate_tilt = tilt
621
+ draw_state.locate_roll = roll
622
+ else:
623
+ spin -= middle_mouse_drag.dx * 0.008 * spin_sign
624
+ # Tilt is UNRESTRICTED - orbit straight over the poles and keep
625
+ # going. remainder() re-wraps into [-pi, pi] (same orientation,
626
+ # cos/sin-continuous) so the stored angle never runs away.
627
+ tilt = math.remainder(tilt + middle_mouse_drag.dy * 0.008, math.tau)
628
+ draw_state.locate_spin = spin
629
+ draw_state.locate_tilt = tilt
630
+ else:
631
+ draw_state._orbit_spin_sign = None
632
+ if double_right_mouse_drag is not None:
633
+ # the old viewer's shading drag: now on a DOUBLE right-drag (the 2nd
634
+ # press of a double right-click, held and dragged): horizontal =
635
+ # brightness, vertical = contrast (up to increase). The plain right-
636
+ # click stays reserved for the context menu.
637
+ cam_brightness = min(4.0, max(0.0, cam_brightness + double_right_mouse_drag.dx * 0.01))
638
+ cam_contrast = min(5.0, max(0.01, cam_contrast - double_right_mouse_drag.dy * 0.008))
639
+ draw_state.locate_cam_brightness = cam_brightness
640
+ draw_state.locate_cam_contrast = cam_contrast
641
+ if scroll_y_changed is not None:
642
+ cam_zoom = min(135.5, max(0.0, cam_zoom * math.exp(-0.23 * scroll_y_changed.value)))
643
+ draw_state.locate_cam_zoom = cam_zoom
644
+ if space_mouse_changed is not None and space_mouse_changed.axes:
645
+ # 3D mouse (events/space_mouse.py → InputHandler.feed()): the six
646
+ # axes integrated over the frame. The mapping is voxel_camera's -
647
+ # turntable writes tilt / spin straight from the puck's pitch / yaw,
648
+ # trackball rotates the basis in view space and decomposes it back
649
+ # (roll is the third angle); pan tracks the screen plane scaled by
650
+ # the camera distance, push / pull is an e-fold dolly. Sensitivities
651
+ # and the mode are Toggles.SpaceMouse. Axes arrive in OBJECT terms
652
+ # (Blender's handiness folded in by space_mouse.normalize()) so this
653
+ # is the same math as the mouse.
654
+ tilt, spin, roll, cam_zoom, (pan_x, pan_y, pan_z) = apply_space_mouse(
655
+ space_mouse_changed.axes, tilt, spin, roll, cam_zoom, (pan_x, pan_y, pan_z),
656
+ navigation=Toggles.SpaceMouse.navigation,
657
+ orbit_sensitivity=float(Toggles.SpaceMouse.orbit_sensitivity),
658
+ pan_sensitivity=float(Toggles.SpaceMouse.pan_sensitivity),
659
+ zoom_sensitivity=float(Toggles.SpaceMouse.zoom_sensitivity),
660
+ pivot=Toggles.SpaceMouse.pivot)
661
+ draw_state.locate_tilt = tilt
662
+ draw_state.locate_spin = spin
663
+ draw_state.locate_roll = roll
664
+ draw_state.locate_cam_zoom = cam_zoom
665
+ draw_state.locate_pan_x = pan_x
666
+ draw_state.locate_pan_y = pan_y
667
+ draw_state.locate_pan_z = pan_z
668
+
669
+ # ── Blender-style numpad views (hover-routed key events): 7/1/3 = top/
670
+ # front/right, ctrl = the opposite side, 5 = ortho toggle, / (either
671
+ # slash, or numpad . like the old viewer) = recenter the pan on the
672
+ # origin. A focused text editor owns the keyboard, so keys are ignored
673
+ # while one is active. ────────────────────────────────────────────────
674
+ if keyboard_available:
675
+ if kp_7_pressed is not None:
676
+ spin, tilt = -(math.pi / 2), (-(math.pi / 2) if kp_7_pressed.ctrl else (math.pi / 2))
677
+ draw_state.locate_spin = spin
678
+ draw_state.locate_tilt = tilt
679
+ if kp_1_pressed is not None:
680
+ spin, tilt = ((math.pi / 2) if kp_1_pressed.ctrl else -(math.pi / 2)), 0.0
681
+ draw_state.locate_spin = spin
682
+ draw_state.locate_tilt = tilt
683
+ if kp_3_pressed is not None:
684
+ spin, tilt = (math.pi if kp_3_pressed.ctrl else 0.0), 0.0
685
+ draw_state.locate_spin = spin
686
+ draw_state.locate_tilt = tilt
687
+ if kp_5_pressed is not None:
688
+ ortho = not ortho
689
+ draw_state.locate_ortho = ortho
690
+ if (slash_pressed is not None or kp_divide_pressed is not None
691
+ or kp_decimal_pressed is not None):
692
+ pan_x = pan_y = pan_z = 0.0
693
+ draw_state.locate_pan_x = 0.0
694
+ draw_state.locate_pan_y = 0.0
695
+ draw_state.locate_pan_z = 0.0
696
+ # ...and level the horizon (a trackball session's habit).
697
+ roll = 0.0
698
+ draw_state.locate_roll = 0.0
699
+
700
+ # ── plane side latch: when the view is upside-down the target plane
701
+ # belongs on the box's OTHER face (the floor light stays fixed in world
702
+ # space; only the catcher's marches see a mirrored box so the flipped
703
+ # floor still catches a shadow). Same idea as _orbit_spin_sign: the side
704
+ # only re-reads orientation while NO drag is active - mid-drag the floor
705
+ # holds put, and the flip lands when you let go.
706
+ if middle_mouse_drag is None:
707
+ draw_state._plane_side = -1.0 if math.cos(tilt) < 0.0 else 1.0
708
+ plane_side = getattr(draw_state, "_plane_side", None) or (
709
+ -1.0 if math.cos(tilt) < 0.0 else 1.0)
710
+
711
+ # Filtering is sampler state on the texture, view-owned, applied per frame.
712
+ filt = gl.GL_NEAREST if nearest else gl.GL_LINEAR
713
+ if isinstance(tex, GLTexture):
714
+ gl.glBindTexture(tex.target, tex.texture_id)
715
+ gl.glTexParameteri(tex.target, gl.GL_TEXTURE_MIN_FILTER, filt)
716
+ gl.glTexParameteri(tex.target, gl.GL_TEXTURE_MAG_FILTER, filt)
717
+ gl.glBindTexture(tex.target, 0)
718
+
719
+ volume_scale = _volume_scale(tex.shape)
720
+
721
+ lut_tex = luts.texture(lut)
722
+
723
+ # ── axis coordinate positions: visible silhouette spans via the Python
724
+ # mirror of the shader camera, computed BEFORE the GL pass - the label
725
+ # billboards render INTO the voxel FBO with the volume's own camera ────
726
+ axis_edges = None
727
+ if axis_display:
728
+ axis_edges = _axis_edges(tilt, spin, cam_zoom,
729
+ width / height, width, height,
730
+ scale=volume_scale,
731
+ pan=(pan_x, pan_y, pan_z),
732
+ ortho=ortho, roll=roll)
733
+
734
+ # A dragged step size can cross zero (the number token has no floor). A
735
+ # non-positive step walks the GL path BACKWARDS out of the box on its
736
+ # first step, so every slice shows only its entry voxel - a flat plane
737
+ # cut along the data's edges (the cuda path only uses it as a sampling
738
+ # stride and shrugged it off, the "GL path looks broken" ticket on
739
+ # 09-08). Floor it here, once, for both paths.
740
+ step_size = max(float(step_size), 1e-5)
741
+
742
+ # ── GL pass: every resource tracked + lifecycle-managed by gl_state ──
743
+ fb = gl_state.fbo("target", width, height)
744
+ depth_was_on = gl.glIsEnabled(gl.GL_DEPTH_TEST)
745
+ with fb:
746
+ gl.glDisable(gl.GL_DEPTH_TEST)
747
+ gl.glClearColor(0.0, 0.0, 0.0, 0.0)
748
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
749
+ # Labels FIRST, so the volume pass composites OVER them - the floor
750
+ # shadow (and the floor itself) darkens the labels beneath it
751
+ # instead of the labels floating on top of the shadow.
752
+ if axis_edges and (name_size > 0 or num_size > 0):
753
+ # Labels as in-scene textured quads. A bake/render hiccup should
754
+ # not take down the view (or trigger the hotswap auto-revert) -
755
+ # log it and keep rendering the volume.
756
+ try:
757
+ specs = _billboard_specs(axis_edges, axis_display,
758
+ volume_scale, name_size, name_padding,
759
+ name_opacity, num_size, num_padding,
760
+ num_opacity, num_spacing, num_angle)
761
+ cam = {"tilt": tilt, "spin": spin, "roll": roll, "zoom": cam_zoom,
762
+ "pan_x": pan_x, "pan_y": pan_y, "pan_z": pan_z,
763
+ "ortho": ortho, "aspect": width / height}
764
+ _render_label_billboards(
765
+ gl_state, specs, cam, height,
766
+ font=font_manager.get(Font.JETBRAINS_MONO_30) if font_manager else None)
767
+ except Exception as e:
768
+ if not voxel_state.label_warned:
769
+ voxel_state.label_warned = True
770
+ import traceback
771
+ print(f"label billboards disabled: {e}")
772
+ traceback.print_exc()
773
+ # The volume shader outputs PREMULTIPLIED alpha (the shader
774
+ # composites with (1-a) weights), so it layers over the labels with
775
+ # ONE / ONE_MINUS_SRC_ALPHA - over label-free (transparent) pixels
776
+ # this is bit-identical to the old unblended write.
777
+ _blend_was = bool(gl.glIsEnabled(gl.GL_BLEND))
778
+ gl.glEnable(gl.GL_BLEND)
779
+ gl.glBlendEquation(gl.GL_FUNC_ADD)
780
+ gl.glBlendFuncSeparate(gl.GL_ONE, gl.GL_ONE_MINUS_SRC_ALPHA,
781
+ gl.GL_ONE, gl.GL_ONE_MINUS_SRC_ALPHA)
782
+ # int() so a UI-dragged float never flips the uniform's inferred
783
+ # GLSL type (the loop bound must be an int).
784
+ if isinstance(tex, CudaVolumeView):
785
+ # The CUDA kernel already produced the premultiplied image (on
786
+ # the tensor's GPU, hopped to a display-GPU RGBA16F texture);
787
+ # blit it into the FBO under the same blend state so labels,
788
+ # outline and the rest of the view are untouched.
789
+ from meltygui.view import voxel_cuda_view
790
+ img_tex = _cuda_render(
791
+ gl_state, tex, width, height, voxel_state, lut=lut, tilt=tilt, spin=spin,
792
+ lut_texture=lut_tex,
793
+ roll=roll, zoom=cam_zoom, pan=(pan_x, pan_y, pan_z), ortho=bool(ortho),
794
+ volume_scale=volume_scale, step_size=float(step_size),
795
+ max_steps=int(max_steps), density=float(density),
796
+ threshold=float(threshold), brightness=float(cam_brightness),
797
+ contrast=float(cam_contrast), gamma=float(Toggles.Voxels.gamma),
798
+ centered=bool(centered),
799
+ shade=voxel_cuda_view.shade_params(
800
+ draw_plane=bool(draw_plane), shadow_opacity=float(shadow_opacity),
801
+ shadow_softness=float(shadow_softness),
802
+ shadow_tint=tuple(float(c) for c in Toggles.Voxels.floor_shadow_color)[:3],
803
+ plane_side=float(plane_side), draw_shading=bool(draw_shading),
804
+ self_shading=bool(self_shading),
805
+ light_pos=tuple(float(c) for c in light_pos),
806
+ light_tint=tuple(float(c) for c in light_tint),
807
+ light_brightness=float(light_brightness),
808
+ ambient_light=float(ambient_light),
809
+ shading_strength=float(shading_strength)))
810
+ if img_tex is not None:
811
+ image_blit_pass(gl_state, image=img_tex)
812
+ else:
813
+ voxel_pass(gl_state, volume=tex, volume_lin=tex, lut=lut_tex,
814
+ aspect=width / height,
815
+ volume_scale=volume_scale, step_size=step_size,
816
+ max_steps=int(max_steps), density=density,
817
+ threshold=threshold, tilt=tilt, spin=spin, roll=roll, zoom=cam_zoom,
818
+ pan_x=pan_x, pan_y=pan_y, pan_z=pan_z, ortho=ortho,
819
+ brightness=cam_brightness, contrast=cam_contrast,
820
+ gamma=float(Toggles.Voxels.gamma), centered=centered,
821
+ draw_plane=bool(draw_plane),
822
+ shadow_opacity=float(shadow_opacity),
823
+ shadow_softness=float(shadow_softness),
824
+ # The floor shadow's own colour (neutral grey), not
825
+ # the UI's blue-black compositor Toggles.floor_color.
826
+ shadow_tint=tuple(float(c) for c in Toggles.Voxels.floor_shadow_color)[:3],
827
+ draw_shading=bool(draw_shading),
828
+ self_shading=bool(self_shading),
829
+ plane_side=float(plane_side),
830
+ light_pos=tuple(float(c) for c in light_pos),
831
+ light_tint=tuple(float(c) for c in light_tint),
832
+ light_brightness=float(light_brightness),
833
+ ambient_light=float(ambient_light),
834
+ shading_strength=float(shading_strength))
835
+ if not _blend_was:
836
+ gl.glDisable(gl.GL_BLEND)
837
+ if depth_was_on:
838
+ gl.glEnable(gl.GL_DEPTH_TEST)
839
+
840
+ img_pos = imgui.get_cursor_screen_pos()
841
+ imgui.image(fb.texture_id, width, height, uv0=(0, 1), uv1=(1, 0))
842
+ clamp_note = getattr(tex, "clamp_note", None)
843
+ if clamp_note:
844
+ # The volume on screen is a PREFIX of the tensor; say so on the image
845
+ # (top-left, wrapped to the image width) rather than silently
846
+ # showing a truncated tensor.
847
+ _draw_image_notice(img_pos, width, clamp_note)
848
+ if not isinstance(src, GLTexture):
849
+ _draw_tensor_meta(img_pos, height, t)
850
+
851
+ # ── the outline stays 2-D imgui (crisp 1px outline over the volume) ────
852
+ if axis_edges:
853
+ _draw_axis_lines(imgui.get_window_draw_list(), img_pos, axis_edges)
854
+
855
+ # ── the slice sliders, one slider per unmapped dim under the volume. An
856
+ # edit writes the full-length slices tuple to draw_state (auto-state:
857
+ # it diverges the param, persists, and resets slice_volume's version so
858
+ # the volume re-slices + re-uploads on the next frame). ──────────────
859
+ _draw_slice_sliders(draw_state, slider_dims, dim_names, slices, source_shape, width)
860
+
861
+ # ── ALL controls live in a satellite panel opening to the RIGHT of
862
+ # the window: the renderer's full params, rendered automatically -
863
+ # draw_state.locate_params is a live dict over this signature, each row
864
+ # reads its framework-resolved value and an edit goes through
865
+ # set_anywhere (draw_state by default; a higher-pri source like an
866
+ # annotation comment claims the write when it drives the param).
867
+ # POPOVER window_pos is relative to the CURSOR at the call,
868
+ # so anchor at the window's right edge - the panel rides along if the
869
+ # window is dragged. Double-click the volume to show/hide; `closed` is
870
+ # only PASSED on init/toggle so the window's own X button works - the
871
+ # framework owns the state between toggles and we mirror it back (a
872
+ # forced closed= every call reopened the panel on the next pre-render,
873
+ # which is why the X appeared dead). ─────────────────────────────────
874
+ init = voxel_state.params_panel is None
875
+ if init:
876
+ # Adopt the same view's saved panel state during the migration.
877
+ voxel_state.params_panel = draw_state.misc.pop("params_panel", False)
878
+ toggled = False
879
+ if left_mouse_double_clicked is not None:
880
+ voxel_state.params_panel = not voxel_state.params_panel
881
+ toggled = True
882
+ draw_state.invalidate()
883
+ request_render()
884
+ panel_open = bool(voxel_state.params_panel)
885
+ # Anchor the panel at the window's RIGHT edge (+12px gap). Set every frame
886
+ # so left_offset/top_offset track the right edge and the panel rides along
887
+ # when the window is dragged; the panel's own drag accumulates into
888
+ # window_pos on top of that, so it stays draggable.
889
+ # Save/restore the flow cursor around the panel jump; nested in a scroll
890
+ # view, `win` is the ENCLOSING window, so this teleports the cursor far
891
+ # from this view's box - left unrestored, poisons the parent rect and
892
+ # the group measure (views popped in with a huge height, then the -30
893
+ # self-reference shrank them back 29px a frame).
894
+ panel_kwargs = {"closed": not panel_open} if (init or toggled) else {}
895
+ if (not middle_mouse_drag and not double_right_mouse_drag and scroll_y_changed is None
896
+ and space_mouse_changed is None):
897
+ _flow_cursor = imgui.get_cursor_screen_pos()
898
+ # Anchor y: the enclosing window's top for a window voxel, this ROW's
899
+ # top for a nested one. The panel call emits an inline item at the
900
+ # cursor, and that item is committed into THIS view's group - an
901
+ # anchor at win.abs_top made a nested view's rect span from the row to
902
+ # the window top, so committed heights scaled with scroll distance
903
+ # (the scrollbar jitter as rows crossed the viewport).
904
+ _anchor_y = win.abs_top if draw_state.closable else draw_state.abs_top
905
+ imgui.set_cursor_screen_pos((win.abs_left + (win.width or width) + 12, _anchor_y))
906
+
907
+ changed, _, panel_ds = draw_any(draw_state.locate_params,
908
+ name=f"controls##{draw_state.name}",
909
+ is_tree=False,
910
+ use_cache=False,
911
+ show_name=False,
912
+ layer_offset=7,
913
+ tint=draw_state._kwargs.get("tint", None),
914
+ swoosh_mode=SwooshMode.LINE,
915
+ mode=Modes.WINDOW_PARAMS, show_tint=False,
916
+ parent_window=win, auto_resize=True,
917
+ shadow=False, return_extras=True,
918
+ initial={"expanded": True},
919
+ **panel_kwargs)
920
+ imgui.set_cursor_screen_pos(_flow_cursor)
921
+ if panel_ds is not None:
922
+ voxel_state.params_panel = not panel_ds.closed
923
+ # The panel is cached and must NOT invalidate per drag frame - it
924
+ # rides its blit while a camera gesture writes the params, then
925
+ # catches up ONCE at the gesture edge.
926
+ if not panel_ds.closed:
927
+ if (not pointer_buttons_down and scroll_y_changed is None
928
+ and space_mouse_changed is None) and changed:
929
+ panel_ds.invalidate_up()
930
+
931
+ # ── status bar error surfacing only ────────────────────────────────────
932
+ if voxel_pass.last_error:
933
+ # imgui.set_cursor_screen_pos((draw_state.abs_left, draw_state.abs_top))
934
+ imgui.text_colored(voxel_pass.last_error.splitlines()[0], 1.0, 0.45, 0.40, 1.0)
935
+ if isinstance(tex, CudaVolumeView) and voxel_state.cuda_error:
936
+ imgui.text_colored(voxel_state.cuda_error.splitlines()[0], 1.0, 0.45, 0.40, 1.0)
937
+
938
+ if changed:
939
+ # UP, not self: this view is often nested (a live-value window,
940
+ # a collection row) and its pixels are baked into ancestor blit
941
+ # tiles - a self-only invalidate left the ancestor serving the
942
+ # stale image, so panel edits "didn't take" until something else
943
+ # repushed the ancestor.
944
+ draw_state.invalidate_up()
945
+ request_render()
946
+ return changed, input_value
947
+
948
+ return False, input_value
949
+
950
+
951
+ LABEL_VERT = """
952
+ #version 330 core
953
+ uniform float tilt, spin, roll, zoom, aspect;
954
+ uniform bool ortho;
955
+ uniform vec3 pan;
956
+ layout(location = 0) in vec3 a_anchor; // world point ON the edge
957
+ layout(location = 1) in vec3 a_u; // baseline dir (flipped for reading)
958
+ layout(location = 2) in vec3 a_v; // text-up dir
959
+ layout(location = 3) in vec3 a_out; // unflipped outward dir (placement)
960
+ layout(location = 4) in vec4 a_metrics; // half_w, half_h, offs (NDC), alpha
961
+ layout(location = 5) in vec4 a_uvrect; // u0, v0(bottom), u1, v1(top)
962
+ out vec2 uv;
963
+ out float v_alpha;
964
+ void main() {
965
+ // two-triangle quad from gl_VertexID: corners in {-1,+1}²
966
+ int id = gl_VertexID;
967
+ vec2 q = vec2((id == 1 || id == 2 || id == 4) ? 1.0 : -1.0,
968
+ (id == 2 || id == 4 || id == 5) ? 1.0 : -1.0);
969
+ uv = vec2(mix(a_uvrect.x, a_uvrect.z, q.x * 0.5 + 0.5),
970
+ mix(a_uvrect.y, a_uvrect.w, q.y * 0.5 + 0.5));
971
+ v_alpha = a_metrics.w;
972
+ float ct = cos(tilt);
973
+ vec3 fwd = -vec3(cos(spin) * ct, sin(spin) * ct, sin(tilt));
974
+ vec3 right0 = vec3(-sin(spin), cos(spin), 0.0);
975
+ // roll turns right toward up about the view axis (0 = level horizon,
976
+ // the turntable); the 3D mouse's trackball mode is what writes it.
977
+ vec3 right = right0 * cos(roll) + cross(right0, fwd) * sin(roll);
978
+ vec3 up = cross(right, fwd);
979
+ vec3 eye = pan - fwd * zoom;
980
+ // Screen-constant sizing: metrics arrive in NDC units. The world length
981
+ // that projects to one NDC unit at the ANCHOR's depth is depth/1.7
982
+ // (zoom/1.7 in ortho), so the label keeps its pixel size at any zoom
983
+ // while still anchoring to and foreshortening with the scene.
984
+ float ws = (ortho ? zoom : max(0.05, dot(a_anchor - eye, fwd))) / 1.7;
985
+ vec3 world = a_anchor + (a_out * a_metrics.z
986
+ + a_u * (a_metrics.x * q.x) + a_v * (a_metrics.y * q.y)) * ws;
987
+ vec3 d = world - eye;
988
+ // The voxel ray gen, inverted (same math as _axis_edges): perspective
989
+ // keeps the depth in w for the divide, ortho is a plain scale.
990
+ if (ortho) {
991
+ float s = zoom / 1.7;
992
+ gl_Position = vec4(dot(d, right) / (s * aspect), dot(d, up) / s, 0.0, 1.0);
993
+ } else {
994
+ gl_Position = vec4(1.7 * dot(d, right) / aspect, 1.7 * dot(d, up),
995
+ 0.0, dot(d, fwd));
996
+ }
997
+ }
998
+ """
999
+
1000
+
1001
+ LABEL_FRAG = """
1002
+ #version 330 core
1003
+ uniform sampler2D label;
1004
+ in vec2 uv;
1005
+ in float v_alpha;
1006
+ out vec4 FragColor;
1007
+ void main() {
1008
+ vec4 t = texture(label, uv);
1009
+ FragColor = vec4(t.rgb, t.a * v_alpha);
1010
+ }
1011
+ """
1012
+
1013
+
1014
+ _LABEL_UNIFORMS = ("tilt", "spin", "roll", "zoom", "aspect", "ortho", "pan", "label")
1015
+
1016
+
1017
+ _LABEL_FLOATS = 20 # 4×vec3 + 2×vec4 per instance
1018
+
1019
+
1020
+ _AXIS_NEAR = 0.05
1021
+
1022
+
1023
+ _EDGE_SHORTEN_PX = 14.0
1024
+
1025
+
1026
+ def _label_program(gl_state):
1027
+ """The instanced label program + its uniform-location map, compiled once
1028
+ per GLState (re-created when the GLSL source changes, e.g. on hotswap)."""
1029
+
1030
+ def create():
1031
+ def compile_one(kind, source):
1032
+ s = gl.glCreateShader(kind)
1033
+ gl.glShaderSource(s, source)
1034
+ gl.glCompileShader(s)
1035
+ if gl.glGetShaderiv(s, gl.GL_COMPILE_STATUS) != gl.GL_TRUE:
1036
+ raise RuntimeError(gl.glGetShaderInfoLog(s).decode(errors="replace"))
1037
+ return s
1038
+
1039
+ vs = compile_one(gl.GL_VERTEX_SHADER, LABEL_VERT)
1040
+ fs = compile_one(gl.GL_FRAGMENT_SHADER, LABEL_FRAG)
1041
+ prog = gl.glCreateProgram()
1042
+ gl.glAttachShader(prog, vs)
1043
+ gl.glAttachShader(prog, fs)
1044
+ gl.glLinkProgram(prog)
1045
+ gl.glDeleteShader(vs)
1046
+ gl.glDeleteShader(fs)
1047
+ if gl.glGetProgramiv(prog, gl.GL_LINK_STATUS) != gl.GL_TRUE:
1048
+ raise RuntimeError(gl.glGetProgramInfoLog(prog).decode(errors="replace"))
1049
+ loc = {n: gl.glGetUniformLocation(prog, n) for n in _LABEL_UNIFORMS}
1050
+ return prog, loc
1051
+
1052
+ def delete(value):
1053
+ gl.glDeleteProgram(value[0])
1054
+
1055
+ return gl_state.get("label_prog", create, delete,
1056
+ deps=(hash(LABEL_VERT), hash(LABEL_FRAG)))
1057
+
1058
+
1059
+ def _label_vao(gl_state):
1060
+ """(vao, vbo): one interleaved per-instance buffer (divisor 1 on every
1061
+ attribute — the quad corners come from gl_VertexID, no vertex attribs)."""
1062
+
1063
+ def create():
1064
+ vao = gl.glGenVertexArrays(1)
1065
+ vbo = gl.glGenBuffers(1)
1066
+ gl.glBindVertexArray(vao)
1067
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo)
1068
+ stride = _LABEL_FLOATS * 4
1069
+ offset = 0
1070
+ for slot, n in ((0, 3), (1, 3), (2, 3), (3, 3), (4, 4), (5, 4)):
1071
+ gl.glEnableVertexAttribArray(slot)
1072
+ gl.glVertexAttribPointer(slot, n, gl.GL_FLOAT, gl.GL_FALSE, stride,
1073
+ ctypes.c_void_p(offset))
1074
+ gl.glVertexAttribDivisor(slot, 1)
1075
+ offset += n * 4
1076
+ gl.glBindVertexArray(0)
1077
+ return vao, vbo
1078
+
1079
+ def delete(value):
1080
+ vao, vbo = value
1081
+ gl.glDeleteBuffers(1, [vbo])
1082
+ gl.glDeleteVertexArrays(1, [vao])
1083
+
1084
+ return gl_state.get("label_vao", create, delete)
1085
+
1086
+
1087
+ def _label_atlas(gl_state, texts, font=None):
1088
+ """The strip atlas for this view's label strings, cached until the
1089
+ string SET changes (tick sets only change at zoom thresholds, so
1090
+ re-bakes are rare). `texts` must be a sorted tuple."""
1091
+
1092
+ def create():
1093
+ return bake_texts(texts, gl_state=gl_state, font=font)
1094
+
1095
+ def delete(value):
1096
+ gl.glDeleteTextures([value[0].texture_id])
1097
+
1098
+ return gl_state.get("label_atlas", create, delete, deps=(texts, id(font)))
1099
+
1100
+
1101
+ def _axis_edges(tilt, spin, zoom, aspect, width, height,
1102
+ scale=(1.0, 1.0, 1.0), pan=(0.0, 0.0, 0.0), ortho=False, roll=0.0):
1103
+ """The volume box's silhouette edges, each clipped to its VISIBLE span —
1104
+ the Python mirror of the shader's orbit camera (extents = `scale`, the
1105
+ voxel-count-proportional volume_scale), so lines and labels land exactly
1106
+ on the rendered edges.
1107
+
1108
+ Face visibility is decided in WORLD space: front-facing iff the eye is
1109
+ outside the face's plane (ortho: iff the view direction looks against
1110
+ its normal) — never from projected corner geometry. The old test used
1111
+ the projected quad's shoelace area against an absolute px² threshold and
1112
+ needed all four corners in front of the near plane; on a wide-skinny
1113
+ volume (a (1, 96, 4096) slab is a 1.0 × 0.023 × 0.0002 box) any zoom that
1114
+ makes the data readable puts the camera INSIDE the box's long span, the
1115
+ near corners fell to the behind-camera cutoff, and every face and edge
1116
+ touching them vanished — the axis hid exactly when you zoomed in to
1117
+ read it, and orbiting changed which corners died.
1118
+
1119
+ An edge is on the silhouette iff exactly one adjacent face is front-
1120
+ facing (edge-on faces count as back-facing, so the camera-facing square
1121
+ contributes all four sides in an exact top view); eye inside the box —
1122
+ no face front-facing — keeps all 12, so the box stays outlined and
1123
+ labeled from the inside. Each edge then clips against the near plane in
1124
+ camera space and the image rect in screen space (screen params map back
1125
+ through the perspective-correct 1/z interpolation), so a partially-
1126
+ behind or partially-offscreen axis keeps its on-screen portion.
1127
+
1128
+ Returns [(a, b, pa, pb, t0, t1, z0, z1)]: the ±1 corner sign tuples, the
1129
+ screen endpoints of the visible span, its world-param range over a→b
1130
+ (exactly 0.0 / 1.0 when that end is the true corner), and the camera
1131
+ depths at the visible ends (equal under ortho) for perspective-correct
1132
+ tick placement downstream."""
1133
+ # The shader camera's basis (voxel_camera.basis), in numpy.
1134
+ fwd, right, up = (np.array(v, np.float64) for v in _cam_basis(tilt, spin, roll))
1135
+ eye = np.asarray(pan, np.float64) - fwd * zoom
1136
+ sc = np.asarray(scale, np.float64)
1137
+ # Inverse of the shader's ray gen (rd ∝ fwd*1.7 + right*ndc.x + up*ndc.y,
1138
+ # ndc.x pre-scaled by aspect): ndc = 1.7 * cam_xy / cam_z, x /= aspect.
1139
+ # Ortho divides by the fixed frame half-size (zoom/1.7) instead of the
1140
+ # point's own depth.
1141
+ ortho_denom = max(zoom, 1e-6) / 1.7
1142
+
1143
+ def to_screen(cx, cy, cz):
1144
+ denom = ortho_denom if ortho else cz / 1.7
1145
+ ndx = (cx / denom) / aspect
1146
+ ndy = cy / denom
1147
+ return ((ndx * 0.5 + 0.5) * width, (1.0 - (ndy * 0.5 + 0.5)) * height)
1148
+
1149
+ def face_visible(k, s):
1150
+ # The box is centered on the ORIGIN (pan is the camera target).
1151
+ return (-s * fwd[k] > 1e-12) if ortho else (s * eye[k] > sc[k])
1152
+
1153
+ vis = {(k, s): face_visible(k, s) for k in range(3) for s in (-1, 1)}
1154
+ any_vis = any(vis.values())
1155
+
1156
+ def clip(a, b):
1157
+ # World → camera space (right/up/depth) at both corners.
1158
+ da = np.asarray(a, np.float64) * sc - eye
1159
+ db = np.asarray(b, np.float64) * sc - eye
1160
+ az, bz = float(da @ fwd), float(db @ fwd)
1161
+ if az < _AXIS_NEAR and bz < _AXIS_NEAR:
1162
+ return None
1163
+ t0, t1 = 0.0, 1.0
1164
+ if az < _AXIS_NEAR:
1165
+ t0 = (_AXIS_NEAR - az) / (bz - az)
1166
+ elif bz < _AXIS_NEAR:
1167
+ t1 = (_AXIS_NEAR - az) / (bz - az)
1168
+ ax, ay = float(da @ right), float(da @ up)
1169
+ bx, by = float(db @ right), float(db @ up)
1170
+ cx0, cy0, cz0 = ax + (bx - ax) * t0, ay + (by - ay) * t0, az + (bz - az) * t0
1171
+ cx1, cy1, cz1 = ax + (bx - ax) * t1, ay + (by - ay) * t1, az + (bz - az) * t1
1172
+ pa, pb = to_screen(cx0, cy0, cz0), to_screen(cx1, cy1, cz1)
1173
+ # Liang-Barsky against the image rect.
1174
+ s0, s1 = 0.0, 1.0
1175
+ dx, dy = pb[0] - pa[0], pb[1] - pa[1]
1176
+ for p, q in ((-dx, pa[0]), (dx, width - pa[0]),
1177
+ (-dy, pa[1]), (dy, height - pa[1])):
1178
+ if abs(p) < 1e-9:
1179
+ if q < 0.0:
1180
+ return None
1181
+ continue
1182
+ r = q / p
1183
+ if p < 0.0:
1184
+ if r > s1:
1185
+ return None
1186
+ if r > s0:
1187
+ s0 = r
1188
+ else:
1189
+ if r < s0:
1190
+ return None
1191
+ if r < s1:
1192
+ s1 = r
1193
+
1194
+ def world_u(s):
1195
+ # Screen param → world param over the near-clipped span: 1/z
1196
+ # interpolates linearly in screen space, so u = s-z0/(z1+s-(z0-z1));
1197
+ # ortho z is affine (u = s).
1198
+ return s if ortho else s * cz0 / (cz1 + s * (cz0 - cz1))
1199
+
1200
+ u0, u1 = world_u(s0), world_u(s1)
1201
+ return (a, b,
1202
+ (pa[0] + dx * s0, pa[1] + dy * s0),
1203
+ (pa[0] + dx * s1, pa[1] + dy * s1),
1204
+ t0 + (t1 - t0) * u0, t0 + (t1 - t0) * u1,
1205
+ cz0 + (cz1 - cz0) * u0, cz0 + (cz1 - cz0) * u1)
1206
+
1207
+ edges = []
1208
+ for k in range(3):
1209
+ i, j = (k + 1) % 3, (k + 2) % 3
1210
+ for si in (-1, 1):
1211
+ for sj in (-1, 1):
1212
+ if any_vis and vis[(i, si)] == vis[(j, sj)]:
1213
+ continue # the edge's two faces agree → not visible
1214
+ a, b = [0, 0, 0], [0, 0, 0]
1215
+ a[k], b[k] = -1, 1
1216
+ a[i] = b[i] = si
1217
+ a[j] = b[j] = sj
1218
+ rec = clip(tuple(a), tuple(b))
1219
+ if rec is not None:
1220
+ edges.append(rec)
1221
+ return edges
1222
+
1223
+
1224
+ def _draw_axis_lines(draw_list, img_pos, edges):
1225
+ """The visible silhouette spans as thin imgui lines, shortened near true
1226
+ CORNERS (the original fixed_shorten look); a clipped end (near plane /
1227
+ screen border) runs to its cut, since the edge continues past it. Labels
1228
+ are NOT drawn here any more — they're textured billboards in the voxel
1229
+ FBO (_billboard_specs + _render_label_billboards), so they live in the
1230
+ 3-D scene."""
1231
+ line_col = pack_color(0.9, 0.9, 1.0, 0.5)
1232
+ for a, b, pa, pb, t0, t1, z0, z1 in edges:
1233
+ dx, dy = pb[0] - pa[0], pb[1] - pa[1]
1234
+ length = math.hypot(dx, dy)
1235
+ if length < 0.5:
1236
+ continue # zero-area edge: nothing to draw, skip the div
1237
+ # Short edges shorten proportionally instead of vanishing - the
1238
+ # outline only ever skips sub-2px degenerates.
1239
+ shorten = min(_EDGE_SHORTEN_PX, length * 0.25)
1240
+ sh_a = shorten if t0 == 0.0 else 0.0
1241
+ sh_b = shorten if t1 == 1.0 else 0.0
1242
+ ux, uy = dx / length, dy / length
1243
+ draw_list.add_line(img_pos[0] + pa[0] + ux * sh_a, img_pos[1] + pa[1] + uy * sh_a,
1244
+ img_pos[0] + pb[0] - ux * sh_b, img_pos[1] + pb[1] - uy * sh_b,
1245
+ line_col, 1.0)
1246
+
1247
+
1248
+ def _billboard_specs(edges, axis_display, volume_scale,
1249
+ name_size=24.0, name_padding=34.0, name_opacity=1.0,
1250
+ num_size=16.0, num_padding=11.0, num_opacity=1.0,
1251
+ num_spacing=1.6, num_angle=0.0):
1252
+ """[(text, anchor3, u_dir3, v_dir3, out_dir3, px_h, off_px, alpha)] for
1253
+ every visible silhouette span — the dim name beside the SPAN's midpoint
1254
+ (always on screen, unlike a clipped edge's full midpoint, which can sit
1255
+ behind the camera) plus integer ticks (_tick_values) at their TRUE
1256
+ positions along the edge; a clipped edge labels only its on-screen index
1257
+ range, so a zoomed-in wide volume reads like a scrolled ruler. Anchors
1258
+ are volume-box WORLD points ON the edge. All metrics are screen PIXELS,
1259
+ held at any zoom (the shader depth-converts at each anchor): `*_size` is
1260
+ the label height (0 hides that label type), `*_padding` the GAP between
1261
+ the line and the label's near edge (independent of size), `*_opacity`
1262
+ the tint alpha. u runs along the edge and v outward from the box
1263
+ ("angled perpendicular to the line"); both are flipped for readability —
1264
+ the up-axis flips when the quad shows its back (un-mirrors without
1265
+ reversing the reading direction), then a 180° spin makes text read
1266
+ left-to-right, or bottom-to-top on near-vertical edges. The offset
1267
+ always rides the UNFLIPPED outward direction, so labels never land
1268
+ inside the box. Nothing hides by projected size any more — the
1269
+ face-visibility silhouette already culls truly invisible edges, and
1270
+ _tick_values degrades to just the end values on short edges."""
1271
+ name_off = name_padding + name_size * 0.5 # anchor -> label CENTER
1272
+ num_off = num_padding + num_size * 0.5
1273
+ # tick label slant (optional, not the label plane - matplotlib-style)
1274
+ ca, sa = math.cos(math.radians(num_angle)), math.sin(math.radians(num_angle))
1275
+ pts = [p for e in edges for p in (e[2], e[3])]
1276
+ if not pts:
1277
+ return []
1278
+ scx = sum(p[0] for p in pts) / len(pts) # silhouette's screen centroid
1279
+ scy = sum(p[1] for p in pts) / len(pts)
1280
+
1281
+ specs = []
1282
+ for a, b, pa, pb, t0, t1, z0, z1 in edges:
1283
+ k = next(i for i in range(3) if a[i] != b[i]) # the axis it runs along
1284
+ if a[k] > b[k]: # a = the texcoord-0 end (visible span flips with it)
1285
+ a, b = b, a
1286
+ pa, pb = pb, pa
1287
+ t0, t1 = 1.0 - t1, 1.0 - t0
1288
+ z0, z1 = z1, z0
1289
+ px_len = math.hypot(pb[0] - pa[0], pb[1] - pa[1])
1290
+ if px_len < 0.5:
1291
+ continue # zero-area edge: direction math requires a length
1292
+ name, size = axis_display[k]
1293
+ a3 = tuple(a[i] * volume_scale[i] for i in range(3))
1294
+ b3 = tuple(b[i] * volume_scale[i] for i in range(3))
1295
+ length = math.sqrt(sum((b3[i] - a3[i]) ** 2 for i in range(3))) or 1.0
1296
+ w = tuple((b3[i] - a3[i]) / length for i in range(3)) # a → b, for placement
1297
+ mid_full = tuple((a3[i] + b3[i]) * 0.5 for i in range(3))
1298
+ m_len = math.sqrt(sum(c * c for c in mid_full)) or 1.0
1299
+ out = tuple(c / m_len for c in mid_full) # outward, ⊥ the edge (mid-w = 0)
1300
+ # World endpoints + midpoint of the VISIBLE span (the label anchors).
1301
+ va = tuple(a3[i] + (b3[i] - a3[i]) * t0 for i in range(3))
1302
+ vb = tuple(a3[i] + (b3[i] - a3[i]) * t1 for i in range(3))
1303
+ mid = tuple((va[i] + vb[i]) * 0.5 for i in range(3))
1304
+
1305
+ # TRUE screen directions, not the camera-basis approximation (which
1306
+ # skews under perspective for off-center edges and mirrors oblique
1307
+ # labels): the baseline from the edge's mean projected points, the
1308
+ # outward axis as its perpendicular pointing away from the
1309
+ # silhouette's screen centroid (the world `out` projects into that
1310
+ # half-space for any silhouette edge, so the signs match).
1311
+ u_s = ((pb[0] - pa[0]) / px_len, (pb[1] - pa[1]) / px_len)
1312
+ mxs, mys = (pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5
1313
+ ox, oy = mxs - scx, mys - scy
1314
+ along = ox * u_s[0] + oy * u_s[1]
1315
+ nx, ny = ox - along * u_s[0], oy - along * u_s[1]
1316
+ nl = math.hypot(nx, ny) or 1.0
1317
+ v_s = (nx / nl, ny / nl)
1318
+
1319
+ u, v = w, out
1320
+ # Chirality - readable text needs cross(u_s, v_s) < 0 on a y-down
1321
+ # screen. When the quad shows its back, flip the UP axis - that
1322
+ # un-mirrors top/bottom without reversing the reading direction.
1323
+ if u_s[0] * v_s[1] - u_s[1] * v_s[0] > 0:
1324
+ v = tuple(-c for c in v)
1325
+ # 180° flipping (chirality-preserving): read left-to-right, or
1326
+ # bottom-to-top when the baseline is near-vertical on screen.
1327
+ if u_s[0] < -0.2 * abs(u_s[1]) or (
1328
+ abs(u_s[0]) <= 0.2 * abs(u_s[1]) and u_s[1] > 0):
1329
+ u = tuple(-c for c in u)
1330
+ v = tuple(-c for c in v)
1331
+
1332
+ if name_size > 0:
1333
+ specs.append((name, mid, u, v, out, name_size, name_off, name_opacity))
1334
+ i0, i1 = t0 * size, t1 * size # the visible index range
1335
+ if num_size > 0 and size > 0 and i1 - i0 > 1e-9:
1336
+ # Ticks compress into the DRAWN line span (true-corner ends draw
1337
+ # shortened; clipped ends need to be cut), and the end labels
1338
+ # sit at the visible line ends. Screen px and world params go
1339
+ # through the perspective-correct 1/z map (affine when z0 == z1,
1340
+ # i.e. ortho or an edge parallel to the screen).
1341
+ def u_of(s):
1342
+ return s if z0 == z1 else s * z0 / (z1 + s * (z0 - z1))
1343
+
1344
+ def s_of(up):
1345
+ return up if z0 == z1 else up * z1 / (z0 + up * (z1 - z0))
1346
+
1347
+ inset_px = min(_EDGE_SHORTEN_PX, px_len * 0.25)
1348
+ u_lo = u_of(inset_px / px_len if t0 == 0.0 else 0.0)
1349
+ u_hi = u_of(1.0 - (inset_px / px_len if t1 == 1.0 else 0.0))
1350
+ if num_angle:
1351
+ ut = tuple(ca * u[i] + sa * v[i] for i in range(3))
1352
+ vt = tuple(ca * v[i] - sa * u[i] for i in range(3))
1353
+ else:
1354
+ ut, vt = u, v
1355
+ # Step from the span's AVERAGE screen density; perspective
1356
+ # compresses the far end, so greedily skip interior ticks whose
1357
+ # SCREEN positions crowd the previous one or the end label.
1358
+ ticks = _tick_values(i0, i1, px_len / (i1 - i0), num_size,
1359
+ num_spacing) # [] on an integer-free sliver
1360
+ min_gap = max(1, len(str(ticks[-1] if ticks else 0))) \
1361
+ * 0.62 * num_size * num_spacing
1362
+ placed = []
1363
+ for n, idx in enumerate(ticks):
1364
+ up = u_lo + (u_hi - u_lo) * ((idx - i0) / (i1 - i0))
1365
+ s_px = s_of(up) * px_len
1366
+ if 0 < n < len(ticks) - 1 and placed and (
1367
+ s_px - placed[-1] < min_gap
1368
+ or s_of(u_hi) * px_len - s_px < min_gap):
1369
+ continue
1370
+ placed.append(s_px)
1371
+ p = tuple(va[i] + (vb[i] - va[i]) * up for i in range(3))
1372
+ specs.append((str(idx), p, ut, vt, out, num_size, num_off, num_opacity))
1373
+ return specs
1374
+
1375
+
1376
+ def _render_label_billboards(gl_state, specs, cam, height, font=None):
1377
+ """Draw every label spec into the CURRENT FBO with the volume's camera
1378
+ (`cam` = the camera uniform kwargs) in ONE instanced draw: assemble the
1379
+ per-instance buffer (anchor/axes/metrics/uv-rect per label), upload,
1380
+ glDrawArraysInstanced. Pixel sizes/offsets convert to NDC units against
1381
+ the viewport height (`height`); the shader depth-scales them at each
1382
+ anchor for screen-constant labels."""
1383
+ if not specs:
1384
+ return
1385
+ texts = tuple(sorted({s[0] for s in specs}))
1386
+ atlas, rects = _label_atlas(gl_state, texts, font=font)
1387
+ prog, loc = _label_program(gl_state)
1388
+ vao, vbo = _label_vao(gl_state)
1389
+
1390
+ ndc_per_px = 2.0 / max(1.0, float(height))
1391
+ data = np.empty((len(specs), _LABEL_FLOATS), np.float32)
1392
+ for i, (text, anchor, u, v, out, px_h, off_px, alpha) in enumerate(specs):
1393
+ u0, v0, u1, v1, tw, th = rects[text]
1394
+ half_h = (px_h * 0.5) * ndc_per_px
1395
+ row = data[i]
1396
+ row[0:3] = anchor
1397
+ row[3:6] = u
1398
+ row[6:9] = v
1399
+ row[9:12] = out
1400
+ row[12] = half_h * (tw / max(1, th))
1401
+ row[13] = half_h
1402
+ row[14] = off_px * ndc_per_px
1403
+ row[15] = alpha
1404
+ row[16:20] = (u0, v0, u1, v1)
1405
+
1406
+ blend_was = bool(gl.glIsEnabled(gl.GL_BLEND))
1407
+ prev_prog = gl.glGetIntegerv(gl.GL_CURRENT_PROGRAM)
1408
+ gl.glEnable(gl.GL_BLEND)
1409
+ gl.glBlendEquation(gl.GL_FUNC_ADD)
1410
+ gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA,
1411
+ gl.GL_ONE, gl.GL_ONE_MINUS_SRC_ALPHA)
1412
+ gl.glUseProgram(prog)
1413
+ gl.glUniform1f(loc["tilt"], cam["tilt"])
1414
+ gl.glUniform1f(loc["spin"], cam["spin"])
1415
+ gl.glUniform1f(loc["roll"], cam["roll"])
1416
+ gl.glUniform1f(loc["zoom"], cam["zoom"])
1417
+ gl.glUniform1f(loc["aspect"], cam["aspect"])
1418
+ gl.glUniform1i(loc["ortho"], 1 if cam["ortho"] else 0)
1419
+ gl.glUniform3f(loc["pan"], cam["pan_x"], cam["pan_y"], cam["pan_z"])
1420
+ gl.glUniform1i(loc["label"], 0)
1421
+ gl.glActiveTexture(gl.GL_TEXTURE0)
1422
+ gl.glBindTexture(gl.GL_TEXTURE_2D, atlas.texture_id)
1423
+ gl.glBindVertexArray(vao)
1424
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo)
1425
+ gl.glBufferData(gl.GL_ARRAY_BUFFER, data.nbytes, data, gl.GL_STREAM_DRAW)
1426
+ gl.glDrawArraysInstanced(gl.GL_TRIANGLES, 0, 6, len(specs))
1427
+ gl.glBindVertexArray(0)
1428
+ gl.glUseProgram(prev_prog)
1429
+ if not blend_was:
1430
+ gl.glDisable(gl.GL_BLEND)
1431
+
1432
+
1433
+ VOXEL_FRAG = """
1434
+ #version 330 core
1435
+ in vec2 uv;
1436
+ out vec4 FragColor;
1437
+
1438
+ // Slab intersection with the box [-bounds, +bounds]: (t_enter, t_exit).
1439
+ vec2 rayBox(vec3 ro, vec3 rd, vec3 bounds) {
1440
+ vec3 inv = 1.0 / rd;
1441
+ vec3 t0 = (-bounds - ro) * inv;
1442
+ vec3 t1 = ( bounds - ro) * inv;
1443
+ vec3 lo = min(t0, t1);
1444
+ vec3 hi = max(t0, t1);
1445
+ return vec2(max(max(lo.x, lo.y), lo.z), min(min(hi.x, hi.y), hi.z));
1446
+ }
1447
+
1448
+ // ── shared transfer function: raw volume sample at texcoord p →
1449
+ // (v: remapped LUT coordinate, m: opacity drive). The old viewer's value
1450
+ // pipeline verbatim — contrast about mid-grey, then brightness, on the
1451
+ // GREYSCALE value; `centered` maps signed data so raw 0 sits at the LUT
1452
+ // middle (pair with a diverging LUT) and opacity keys on MAGNITUDE, so
1453
+ // negatives render as strongly as positives. Takes the sampler to read
1454
+ // through: `volume` (the user's nearest/linear toggle) for the color march,
1455
+ // `volume_lin` (always linear — same texture, its own sampler object) for
1456
+ // every shading read, where smooth beats blocky regardless of the toggle.
1457
+ vec2 remapValue(sampler3D vol, vec3 p) {
1458
+ float v = texture(vol, p).r;
1459
+ if (centered) { v = v * 0.5 + 0.5; } // signed [-1,1] -> [0,1]
1460
+ v = (v - 0.5) * contrast + 0.5;
1461
+ float m;
1462
+ if (centered) {
1463
+ v = 0.5 + (v - 0.5) * brightness;
1464
+ v = clamp(v, 0.0, 1.0);
1465
+ m = abs(v - 0.5) * 2.0;
1466
+ } else {
1467
+ v *= brightness;
1468
+ v = clamp(v, 0.0, 1.0);
1469
+ m = v;
1470
+ }
1471
+ return vec2(v, m);
1472
+ }
1473
+
1474
+ // Extended-sRGB decode (hdr_color.py's convention): the sRGB curve
1475
+ // mirrored for negatives, no ceiling — a LUT entry above 1 is brighter than
1476
+ // the desktop's white, a negative one is outside the sRGB gamut (P3). A
1477
+ // plain pow() turned negatives into NaN.
1478
+ vec3 decodeSrgb(vec3 c) { return sign(c) * pow(abs(c), vec3(2.2)); }
1479
+
1480
+ // The old viewer's opacity ramp: values at/above the gate (1 - threshold)
1481
+ // are FULLY opaque — a hard isosurface — and below it opacity falls off as
1482
+ // (m/gate)^4, scaled by density and the volume_scale-NORMALIZED segment
1483
+ // length, so optical depth is a function of the FRACTION of the volume
1484
+ // traversed, not the world path length — a 4096-voxel axis viewed end-on
1485
+ // accumulates the same opacity as an 8-voxel one.
1486
+ float alphaFor(float m, float seg_n) {
1487
+ float gate = 1.0 - clamp(threshold, 0.0, 0.999);
1488
+ if (m >= gate) return 1.0;
1489
+ return clamp(pow(m / gate, 4.0) * density * seg_n * 50.0, 0.0, 1.0);
1490
+ }
1491
+
1492
+ // Transmittance from a world point toward the light: a COARSE fixed-count
1493
+ // march of the SAME transfer function (shadows are low-frequency — fat
1494
+ // steps read clean where the primary ray needs thousands), multiplying out
1495
+ // per-step opacity. The result is graded the way the render is: haze dims
1496
+ // the light, the opaque core blocks it. `steps` sets the quality tier
1497
+ // (the plane's cast shadow affords more than per-sample self-shading) and
1498
+ // `max_dist` optionally caps the march to NEAR-FIELD occluders — for
1499
+ // self-shading, what's right next to a sample is most of its shadow.
1500
+ // Always reads through volume_lin: shading wants smooth fields.
1501
+ // The Info variant also reports WHERE occlusion happened: (T, t_occ) with
1502
+ // t_occ the distance along the light ray at which transmittance first
1503
+ // dropped below 0.5 — the occluder height that drives the ground-space
1504
+ // penumbra radius. Rays that never occlude report the mid-chord of their
1505
+ // box span instead, so pixels just OUTSIDE the umbra blur with the same
1506
+ // radius as their shadowed neighbors (the penumbra widens on BOTH sides
1507
+ // of the hard edge); rays that miss the box entirely report 0.
1508
+ vec2 lightVisibilityInfo(vec3 p, vec3 lp, int steps, float max_dist) {
1509
+ vec3 ld = normalize(lp - p);
1510
+ vec2 span = rayBox(p, ld, volume_scale);
1511
+ float t0 = max(span.x, 0.0);
1512
+ float t1 = min(min(span.y, length(lp - p)), max_dist);
1513
+ if (t0 >= t1) return vec2(1.0, 0.0);
1514
+ float ss = (t1 - t0) / float(steps);
1515
+ float seg_n = ss * length(ld / volume_scale);
1516
+ float T = 1.0;
1517
+ float t_occ = 0.0;
1518
+ float t = t0 + ss * 0.5;
1519
+ for (int i = 0; i < steps; i++) {
1520
+ vec3 q = (p + ld * t) / volume_scale * 0.5 + 0.5;
1521
+ T *= 1.0 - alphaFor(remapValue(volume_lin, q).y, seg_n);
1522
+ if (t_occ == 0.0 && T < 0.5) { t_occ = t; }
1523
+ if (T < 0.02) break; // fully shadowed — stop early
1524
+ t += ss;
1525
+ }
1526
+ if (t_occ == 0.0) { t_occ = 0.5 * (t0 + t1); }
1527
+ return vec2(T, t_occ);
1528
+ }
1529
+
1530
+ float lightVisibility(vec3 p, vec3 lp, int steps, float max_dist) {
1531
+ return lightVisibilityInfo(p, lp, steps, max_dist).x;
1532
+ }
1533
+
1534
+ // Gradient normal at texcoord p (central differences, one voxel apart),
1535
+ // mapped to WORLD space (anisotropic boxes bend gradients), plus a shading
1536
+ // weight (w) that fades to 0 where the gradient is too weak to trust —
1537
+ // uniform haze keeps its flat unshaded look instead of picking up noise.
1538
+ vec4 volumeNormal(vec3 p) {
1539
+ // 1.75-voxel stencil: with the linear sampler this is a genuine lowpass
1540
+ // on the normal field, so hard binary edges (a 0/1 mask's staircase)
1541
+ // shade as smooth ramps instead of per-voxel facets.
1542
+ vec3 e = 1.75 / vec3(textureSize(volume_lin, 0));
1543
+ vec3 g = vec3(
1544
+ texture(volume_lin, p + vec3(e.x, 0, 0)).r - texture(volume_lin, p - vec3(e.x, 0, 0)).r,
1545
+ texture(volume_lin, p + vec3(0, e.y, 0)).r - texture(volume_lin, p - vec3(0, e.y, 0)).r,
1546
+ texture(volume_lin, p + vec3(0, 0, e.z)).r - texture(volume_lin, p - vec3(0, 0, e.z)).r);
1547
+ if (centered) { g *= sign(texture(volume_lin, p).r); } // shade |v|'s surface
1548
+ vec3 gw = g / volume_scale; // texcoord gradient → world
1549
+ float len = length(gw);
1550
+ // The normal points from dense toward empty — that's MINUS the gradient.
1551
+ return vec4(len > 1e-6 ? -gw / len : vec3(0.0, 0.0, 1.0),
1552
+ clamp(length(g) * 6.0, 0.0, 1.0));
1553
+ }
1554
+
1555
+ void main() {
1556
+ // Z-up orbit camera built straight from injected uniforms — tilt, spin, roll,
1557
+ // zoom, pan and ortho arrive as plain Python kwargs, no matrices anywhere.
1558
+ // The basis is analytic in spin/tilt (not cross(fwd, world-up)) so the
1559
+ // numpad top/bottom presets (tilt = ±π/2) stay well-defined; it matches
1560
+ // the old construction everywhere else.
1561
+ float ct = cos(tilt);
1562
+ vec3 fwd = -vec3(cos(spin) * ct, sin(spin) * ct, sin(tilt));
1563
+ vec3 right0 = vec3(-sin(spin), cos(spin), 0.0);
1564
+ // roll turns right toward up about the view axis (0 = level horizon,
1565
+ // the turntable); the 3D mouse's trackball mode is what writes it.
1566
+ vec3 right = right0 * cos(roll) + cross(right0, fwd) * sin(roll);
1567
+ vec3 up = cross(right, fwd);
1568
+ vec3 eye = vec3(pan_x, pan_y, pan_z) - fwd * zoom;
1569
+ vec2 ndc = (uv * 2.0 - 1.0) * vec2(aspect, 1.0);
1570
+ // Perspective rays fan out from the eye; ortho rays march parallel from
1571
+ // a plane through it, sized to match the perspective frame at the target.
1572
+ vec3 ro = ortho ? eye + (right * ndc.x + up * ndc.y) * (zoom / 1.7) : eye;
1573
+ vec3 rd = ortho ? fwd : normalize(fwd * 1.7 + right * ndc.x + up * ndc.y);
1574
+ // Accumulate optical depth per unit of VIEW DEPTH, not per unit of ray
1575
+ // arc length. In perspective, edge rays cross the volume at a steeper
1576
+ // angle and a step's world length (seg) is ~1/cos(theta) longer than a
1577
+ // center ray's, so a thin slab reads denser toward the screen edges (a
1578
+ // screenspace radial artifact — hidden on cubes only because they saturate
1579
+ // the alpha break). cos(angle to fwd) cancels the extra path. Ortho rays
1580
+ // have rd == fwd, so view_cos == 1 and this is a no-op there.
1581
+ float view_cos = dot(rd, fwd);
1582
+
1583
+ // ── shadow catcher: the plane the box rests on (z = -volume_scale.z)
1584
+ // is itself INVISIBLE — its only contribution is the shadow the volume
1585
+ // casts onto it, composited as a darkening with alpha = blocked light.
1586
+ // One-sided (backface-culled analytically): a hit only counts for rays
1587
+ // striking the TOP face — from underneath there's no shadow at all.
1588
+ float plane_t = -1.0;
1589
+ float plane_a = 0.0;
1590
+ // The caught shadow darkens toward shadow_tint — a neutral grey-black
1591
+ // (Toggles.Voxels.floor_shadow_color), decoupled from the blue-black
1592
+ // the rest of the studio's compositor shadows carry.
1593
+ vec3 plane_c = shadow_tint;
1594
+ // plane_side mirrors the catcher to the box's OTHER face when the view
1595
+ // is upside-down (+1 = floor at -z, -1 = at +z; latched between drags
1596
+ // on the Python side). The conditions are the normal ones written in
1597
+ // z' = z * plane_side; the catcher's light is mirrored to match below.
1598
+ if (draw_plane && draw_shading && rd.z * plane_side < -1e-6
1599
+ && ro.z * plane_side > -volume_scale.z) {
1600
+ plane_t = (-volume_scale.z * plane_side - ro.z) / rd.z;
1601
+ vec3 pw = ro + rd * plane_t;
1602
+ // Blocked light via the same transmittance march as the rest of the
1603
+ // shading, lifted by the ambient floor (ambient_light raises this
1604
+ // shadow like every other). The exponential radial fade bounds the
1605
+ // catcher so the darkening dies off instead of cutting.
1606
+ float ext = max(volume_scale.x, volume_scale.y);
1607
+ float r = max(length(pw.xy) - ext * 1.1, 0.0);
1608
+ // Center march gathers (visibility, occluder distance); the blur
1609
+ // then happens in FLOOR coordinates — 4 extra visibility taps on a
1610
+ // ring around the hit point, radius = shadow_softness × occluder
1611
+ // distance (higher occluders throw softer shadows), averaged with
1612
+ // the center. Skipped when the center ray misses the box (t_occ 0
1613
+ // — open floor, nothing to soften).
1614
+ // The catcher's marches use a PLANE-LOCAL light: light_pos with its
1615
+ // z mirrored to the plane's side. The real light stays fixed in
1616
+ // world space (the volume's shading uses it untouched) — this
1617
+ // mirror only makes the flipped floor catch the same silhouette
1618
+ // the bottom floor would, instead of a ceiling catching nothing.
1619
+ vec3 pl_light = vec3(light_pos.xy, light_pos.z * plane_side);
1620
+ vec2 vi = lightVisibilityInfo(pw, pl_light, 24, 1e8);
1621
+ float vis = vi.x;
1622
+ float blur_r = shadow_softness * vi.y;
1623
+ if (blur_r > 1e-4) {
1624
+ float acc_v = vis;
1625
+ for (int k = 0; k < 4; k++) {
1626
+ float ang = float(k) * 1.5707963 + 0.7853982;
1627
+ vec3 op = pw + vec3(cos(ang), sin(ang), 0.0) * blur_r;
1628
+ acc_v += lightVisibility(op, pl_light, 10, 1e8);
1629
+ }
1630
+ vis = acc_v / 5.0;
1631
+ }
1632
+ float shadow = (1.0 - ambient_light) * (1.0 - vis);
1633
+ plane_a = clamp(shadow * shadow_opacity, 0.0, 1.0) * exp(-1.5 * r / ext);
1634
+ }
1635
+
1636
+ // volume_scale: box extents per axis, voxel-count-proportional — so each
1637
+ // VOXEL is a cube and the tensor keeps its true shape.
1638
+ vec2 hit = rayBox(ro, rd, volume_scale);
1639
+ bool box_hit = !(hit.x > hit.y || hit.y < 0.0);
1640
+ if (!box_hit && plane_t <= 0.0) { FragColor = vec4(0.0); return; }
1641
+
1642
+ vec4 acc = vec4(0.0);
1643
+ // Plane in FRONT of the volume (looking down at foreground floor past
1644
+ // the box — possible since the fade extends beyond it): composite it
1645
+ // first. The box's bottom face lies IN the plane, so a ray never
1646
+ // crosses the plane mid-march — it's strictly before or after the box.
1647
+ if (plane_t > 0.0 && box_hit && plane_t <= max(hit.x, 0.0)) {
1648
+ acc = vec4(plane_c * plane_a, plane_a);
1649
+ plane_t = -1.0;
1650
+ }
1651
+
1652
+ float t = max(hit.x, 0.0);
1653
+ // max_steps is a watchdog: the break on hit.y is what normally ends the
1654
+ // march. The whole box is covered only while max_steps * step_size
1655
+ // exceeds the worst-case chord (2*sqrt(3) ≈ 3.46 units) — a granular
1656
+ // step_size needs a higher cap or the far side of the volume clips away.
1657
+ for (int i = 0; i < max_steps; i++) {
1658
+ if (t >= hit.y || acc.a > 0.98) break;
1659
+ // Weight each sample by the segment it actually covers (the tail is
1660
+ // partial) and sample at the segment MIDPOINT: a slab thinner than
1661
+ // one step then accumulates opacity proportional to its true path
1662
+ // length instead of jumping by whole steps as the sample count
1663
+ // changes with view angle — the concentric-ring artifact on thin
1664
+ // tensors.
1665
+ float seg = min(step_size, hit.y - t);
1666
+ vec3 p = (ro + rd * (t + seg * 0.5)) / volume_scale * 0.5 + 0.5;
1667
+ // Value pipeline + opacity ramp live in remapValue/alphaFor (shared
1668
+ // with the shadow march). `lut` is a 1-D texture the LUT host baked
1669
+ // from a flat [r,g,b,...] float list.
1670
+ vec2 vm = remapValue(volume, p);
1671
+ float seg_n = seg * length(rd / volume_scale);
1672
+ float a = alphaFor(vm.y, seg_n * view_cos);
1673
+ if (a > 0.0) {
1674
+ // Composite in LINEAR light: the LUT tables are display-referred
1675
+ // sRGB, so decode each sample before accumulating (encode once at
1676
+ // the end). Blending in sRGB space skews mixes toward the more
1677
+ // saturated component — the old harsh/garish translucency.
1678
+ vec3 c = decodeSrgb(texture(lut, vm.x).rgb);
1679
+ if (draw_shading) {
1680
+ // Gradient-normal Lambert, weighted by gradient strength so
1681
+ // flat haze keeps its unshaded look; self_shading adds a
1682
+ // FAST near-field transmittance march toward the light —
1683
+ // 6 fat linear-filtered steps capped close to the sample,
1684
+ // since nearby occluders are most of a sample's shadow.
1685
+ // Both the normal taps and the march are skipped where they
1686
+ // can't show: sub-1% alpha samples, and (for the march)
1687
+ // gradient weight ≈ 0 — the mix would erase it anyway.
1688
+ float shade = 1.0;
1689
+ if (a > 0.01) {
1690
+ vec4 nw = volumeNormal(p);
1691
+ if (nw.w > 0.01) {
1692
+ vec3 wp = ro + rd * (t + seg * 0.5);
1693
+ // Half-Lambert wrap: (n·l/2 + 1/2)² instead of the
1694
+ // hard max(n·l, 0). Faces pointing away from the
1695
+ // light dim gently rather than clamping to the
1696
+ // ambient floor — on binary data the hard clamp
1697
+ // turned every off-facing step facet into the same
1698
+ // flat dark block.
1699
+ float ndl = dot(nw.xyz, normalize(light_pos - wp)) * 0.5 + 0.5;
1700
+ float vis = self_shading
1701
+ ? lightVisibility(wp, light_pos, 6, 0.7) : 1.0;
1702
+ shade = mix(1.0, ambient_light
1703
+ + (1.0 - ambient_light) * ndl * ndl * vis,
1704
+ nw.w * shading_strength);
1705
+ }
1706
+ }
1707
+ c *= decodeSrgb(light_tint) * light_brightness * shade;
1708
+ }
1709
+ acc.rgb += (1.0 - acc.a) * a * c;
1710
+ acc.a += (1.0 - acc.a) * a;
1711
+ }
1712
+ t += step_size;
1713
+ }
1714
+ // Plane BEHIND the volume (the usual case): composite it under
1715
+ // whatever the march accumulated.
1716
+ if (plane_t > 0.0) {
1717
+ acc.rgb += (1.0 - acc.a) * plane_c * plane_a;
1718
+ acc.a += (1.0 - acc.a) * plane_a;
1719
+ }
1720
+ // The target is the linear fp16 scene (hdr_color.py): no sRGB encode
1721
+ // here, the presentation pass does that once. `gamma` is an artistic
1722
+ // curve on the linear image, 1.0 = untouched (the colorimetric result),
1723
+ // above 1 darkens the mids against the studio's dark UI. Mirrored for
1724
+ // negatives (P3 rides as negative scRGB). No dither: the fp16 target
1725
+ // doesn't band.
1726
+ FragColor = vec4(sign(acc.rgb) * pow(abs(acc.rgb), vec3(gamma)), acc.a);
1727
+ }
1728
+ """
1729
+
1730
+
1731
+ @shader_func(fragment=VOXEL_FRAG)
1732
+ def voxel_pass(gl_state: GLState = None, tilt=0.5, spin=0.8, roll=0.0, zoom=3.4,
1733
+ pan_x=0.0, pan_y=0.0, pan_z=0.0, ortho=False,
1734
+ aspect=1.0, brightness=1.0, contrast=1.0, density=1.0, gamma=1.6,
1735
+ threshold=0.1, step_size=0.0015, max_steps=4096, centered=False,
1736
+ volume=None, volume_lin=None, lut=None,
1737
+ draw_plane=True, shadow_opacity=1.0, shadow_softness=0.15,
1738
+ shadow_tint=(0.0, 0.02, 0.05), plane_side=1.0,
1739
+ draw_shading=True, self_shading=True,
1740
+ light_pos=(90.0, -90.0, 200.0), light_tint=(1.0, 1.0, 1.0),
1741
+ light_brightness=1.622, ambient_light=0.3, shading_strength=0.7,
1742
+ volume_scale=(1.0, 1.0, 1.0), program=None, **kwargs):
1743
+ # Program bound, uniforms set. volume_lin is the SAME texture as volume
1744
+ # on its own unit; a GL sampler object forces LINEAR filtering on that
1745
+ # unit so the shading reads a smooth field, while the core march keeps
1746
+ # the user's nearest/linear choice (filtering is texture-object state -
1747
+ # a bound sampler object is the rare GL mechanism that overrides it
1748
+ # per unit).
1749
+ def create():
1750
+ s = int(gl.glGenSamplers(1))
1751
+ gl.glSamplerParameteri(s, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR)
1752
+ gl.glSamplerParameteri(s, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
1753
+ for w in (gl.GL_TEXTURE_WRAP_S, gl.GL_TEXTURE_WRAP_T,
1754
+ gl.GL_TEXTURE_WRAP_R):
1755
+ gl.glSamplerParameteri(s, w, gl.GL_CLAMP_TO_EDGE) # = texture3d's
1756
+ return s
1757
+ sampler = gl_state.get("volume_lin_sampler", create,
1758
+ lambda v: gl.glDeleteSamplers(1, [int(v)]))
1759
+ unit = -1
1760
+ loc = gl.glGetUniformLocation(program, "volume_lin")
1761
+ if loc >= 0:
1762
+ buf = np.zeros(1, np.int32)
1763
+ gl.glGetUniformiv(program, loc, buf)
1764
+ unit = int(buf[0])
1765
+ gl.glBindSampler(unit, sampler)
1766
+ gl.glBindVertexArray(gl_state.vao("fs_triangle"))
1767
+ gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3)
1768
+ if unit >= 0:
1769
+ gl.glBindSampler(unit, 0) # sampler bindings outlive the draw call
1770
+
1771
+
1772
+ def _cuda_march_ready():
1773
+ try:
1774
+ from meltygui.core.graphics.cuda_kernel_core import available
1775
+ return available()
1776
+ except Exception:
1777
+ return False
1778
+
1779
+
1780
+ def _cuda_render(gl_state, cv, width, height, voxel_state: VoxelState, lut="jet", shade=None,
1781
+ lut_texture=None, **cam):
1782
+ """Run the CUDA raymarcher over `cv` (CudaVolumeView) at width×height
1783
+ and return a display-GPU RGBA16F GLTexture holding the premultiplied
1784
+ LINEAR image (HDR headroom and P3 negatives intact, hdr_color.py) — or
1785
+ None (error recorded in voxel_state.cuda_error, drawn as status).
1786
+ The shared LUT and per-view output image live on the TENSOR's device;
1787
+ a pinned host buffer carries the image over, and `cuda_image`
1788
+ is the GL texture it lands in (all re-made only when size/device/LUT
1789
+ change)."""
1790
+ import torch
1791
+ from meltygui.view import voxel_cuda_view
1792
+ dev = cv.view.device
1793
+ W, H = int(width), int(height)
1794
+ try:
1795
+ out = gl_state.get("cuda_out",
1796
+ lambda: torch.empty(H, W, 4, dtype=torch.float16, device=dev),
1797
+ deps=(W, H, str(dev)))
1798
+ if lut_texture is None:
1799
+ from meltygui.model.lut_model import LutTexture, make_luts, lut_values
1800
+ lut_texture = gl_state.get(('cuda_lut_proxy', str(lut)),
1801
+ lambda: LutTexture(lut_values(make_luts(), lut)))
1802
+ lut_t = lut_texture.cuda(dev)
1803
+ # shading params ride one small device array, re-uploaded only when
1804
+ # a value changes (deps = the values themselves)
1805
+ shade_list = list(shade) if shade is not None else voxel_cuda_view.shade_params()
1806
+ shade_t = gl_state.get("cuda_shade",
1807
+ lambda: torch.tensor(shade_list, dtype=torch.float32, device=dev),
1808
+ deps=(tuple(shade_list), str(dev)))
1809
+ # Shading mip: baked once per volume version (one full volume read),
1810
+ # then every shading tap reads the few-MB dense copy instead of the
1811
+ # strided source - keeping shading cost independent of tensor size.
1812
+ _transfer = (float(cam["threshold"]), float(cam["density"]),
1813
+ float(cam["brightness"]), float(cam["contrast"]),
1814
+ bool(cam["centered"]))
1815
+ # The (j, k) mip is the colour march's TRAVERSAL grid now (two-level
1816
+ # DDA skips empty cells through it) as well as self-shading's light
1817
+ # field; it bakes OPACITY under the current transfer, so the key
1818
+ # includes it. Always baked on the cuda path.
1819
+ mip = gl_state.get(
1820
+ "cuda_mip",
1821
+ lambda: voxel_cuda_view.build_mip(
1822
+ cv.view, display_shape=cv.shape, nf=cv.nf, norm=cv.norm,
1823
+ threshold=_transfer[0], density=_transfer[1],
1824
+ brightness=_transfer[2], contrast=_transfer[3],
1825
+ centered=_transfer[4]),
1826
+ deps=(cv._vol_key, cv.shape, str(dev), _transfer))
1827
+ # Floor map is a BAKED full-res map - the plane's shadow depends on
1828
+ # the volume/light/transfer, never the camera, so it re-bakes only
1829
+ # when those change (slice slider, tensor version, light or
1830
+ # brightness edit), and orbiting reads it for free. Softness happens
1831
+ # live (the blur is map taps at render time).
1832
+ floor_map, floor_R = None, (0.0, 0.0)
1833
+ if shade_list[0] > 0.5 and shade_list[7] > 0.5: # draw_floor
1834
+ _light = (tuple(shade_list[9:12]), float(shade_list[6])) # pos, side
1835
+ vsc = cam["volume_scale"]
1836
+ floor_R = voxel_cuda_view.floor_map_extent(vsc)
1837
+ floor_map = gl_state.get(
1838
+ "cuda_floor",
1839
+ lambda: voxel_cuda_view.build_floor_map(
1840
+ cv.view, display_shape=cv.shape, volume_scale=vsc,
1841
+ nf=cv.nf, norm=cv.norm,
1842
+ threshold=_transfer[0], density=_transfer[1],
1843
+ brightness=_transfer[2], contrast=_transfer[3],
1844
+ centered=_transfer[4],
1845
+ light_pos=_light[0], plane_side=_light[1]),
1846
+ deps=(cv._vol_key, cv.shape, str(dev), _transfer, _light,
1847
+ tuple(round(float(v), 5) for v in vsc)))
1848
+ voxel_cuda_view.march(cv.view, out, lut_t, display_shape=cv.shape, nf=cv.nf,
1849
+ norm=cv.norm, aspect=W / H, shade=shade_t, mip=mip,
1850
+ floor_map=floor_map, floor_extent=floor_R, **cam)
1851
+ img = _upload_cuda_image(gl_state, out)
1852
+ voxel_state.cuda_error = None
1853
+ return img
1854
+ except Exception as e:
1855
+ msg = f"cuda_march failed: {e}"
1856
+ if msg != voxel_state.cuda_error:
1857
+ print(f"[voxels] {msg}")
1858
+ print_stack_trace()
1859
+ voxel_state.cuda_error = msg
1860
+ return None