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
meltygui/image_load.py ADDED
@@ -0,0 +1,308 @@
1
+ """Load an image (a file, or encoded bytes) as linear scRGB float32 (h, w, 3), 1.0 = SDR white (``sdr_white`` nits).
2
+
3
+ meltygui's copy of hdr-viewer's image_load.py (~/Desktop/hdr-viewer), the reference decoder; `load_bytes` is the
4
+ addition for the chat transcript's inline images (base64 payloads in a session).
5
+
6
+ That is meltygui's working space: sRGB primaries, linear light, no ceiling, negatives
7
+ allowed (BT.2020 colours outside sRGB come out negative and survive the fp16 path).
8
+
9
+ Three kinds of file:
10
+ * 16-bit PNG with a PQ cICP chunk (what screenshot-hdr writes). Decoded here
11
+ by hand (Pillow flattens 16-bit RGB to 8-bit), PQ -> nits, BT.2020 -> sRGB
12
+ primaries, divided by ``sdr_white`` — the reference the FILE was authored
13
+ against (203 nits, BT.2408), not the desktop's SDR white.
14
+ * 8-bit PQ: a PNG with a PQ cICP chunk (Blender's HDR output) or a JPEG (or
15
+ anything Pillow opens) with a PQ ICC profile ("Rec2020 Gamut with PQ
16
+ Transfer"). Pillow decodes, then the same PQ path through a 256-entry LUT.
17
+ * anything else Pillow opens: treated as sRGB, 1.0 = SDR white. (A wide-gamut
18
+ SDR ICC profile such as Display P3 is not honoured yet.)
19
+ """
20
+ import ctypes
21
+ import hashlib
22
+ import os
23
+ import pathlib
24
+ import struct
25
+ import subprocess
26
+ import zlib
27
+
28
+ import numpy as np
29
+
30
+ # chromaticities -> RGB->XYZ, and the BT.2020 -> sRGB matrix (both D65, no adaptation)
31
+ def _rgb_to_xyz(xy):
32
+ xy = np.asarray(xy, dtype=float)
33
+ xyz = np.stack([xy[:, 0] / xy[:, 1], np.ones(4), (1 - xy.sum(axis=1)) / xy[:, 1]], axis=0)
34
+ m = xyz[:, :3]
35
+ return m @ np.diag(np.linalg.solve(m, xyz[:, 3]))
36
+
37
+ BT2020_XY = [[.708, .292], [.170, .797], [.131, .046], [.3127, .3290]]
38
+ SRGB_XY = [[.64, .33], [.30, .60], [.15, .06], [.3127, .3290]]
39
+ P3_XY = [[.680, .320], [.265, .690], [.150, .060], [.3127, .3290]]
40
+ BT2020_TO_SRGB = np.linalg.solve(_rgb_to_xyz(SRGB_XY), _rgb_to_xyz(BT2020_XY))
41
+ # CICP (H.273) colour primaries code -> matrix to sRGB primaries
42
+ PRIMARIES_TO_SRGB = {1: np.eye(3), 9: BT2020_TO_SRGB,
43
+ 12: np.linalg.solve(_rgb_to_xyz(SRGB_XY), _rgb_to_xyz(P3_XY))}
44
+ CICP_PQ = 16
45
+
46
+
47
+ def pq_decode(v):
48
+ """PQ code values in [0, 1] -> nits."""
49
+ p = np.maximum(v, 0) ** (1 / (2523 / 32))
50
+ return (np.maximum(p - 3424 / 4096, 0) / (2413 / 128 - (2392 / 128) * p)) ** (1 / (2610 / 16384)) * 10000
51
+
52
+
53
+ _PQ16_LUT = None
54
+
55
+
56
+ def pq16_to_nits():
57
+ """nits for every 16-bit PQ code, float32 (65536,). Indexing a 1080p frame
58
+ through it is ~10 ms; evaluating pq_decode over the frame was ~100 ms."""
59
+ global _PQ16_LUT
60
+ if _PQ16_LUT is None:
61
+ _PQ16_LUT = pq_decode(np.arange(65536, dtype=np.float64) / 65535.0).astype(np.float32)
62
+ return _PQ16_LUT
63
+
64
+
65
+ def pq8_to_nits():
66
+ return pq_decode(np.arange(256, dtype=np.float64) / 255.0).astype(np.float32)
67
+
68
+
69
+ def pq_to_linear(codes, primaries, sdr_white, alpha=None):
70
+ """PQ code values (uint8 or uint16 (h, w, 3)) in CICP ``primaries`` ->
71
+ linear scRGB float32, 1.0 = ``sdr_white`` nits; alpha (same dtype) composites
72
+ over black, like the compositor would. Returns (linear, peak_nits)."""
73
+ lut = pq16_to_nits() if codes.dtype == np.uint16 else pq8_to_nits()
74
+ nits = lut[codes]
75
+ if alpha is not None and alpha.min() != np.iinfo(alpha.dtype).max:
76
+ nits *= alpha.astype(np.float32)[..., None] / np.float32(np.iinfo(alpha.dtype).max)
77
+ m = (PRIMARIES_TO_SRGB.get(primaries, BT2020_TO_SRGB) / float(sdr_white)).astype(np.float32)
78
+ return nits @ m.T, float(nits.max()) # fp32 throughout: ~2 ms for 1080p
79
+
80
+
81
+ def srgb_to_linear(v):
82
+ v = np.asarray(v, dtype=np.float32)
83
+ return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
84
+
85
+
86
+ # --- 16-bit PNG ----------------------------------------------------------------
87
+
88
+ _UNFILTER_SRC = pathlib.Path(__file__).with_name('png_unfilter.c')
89
+ _native = None # C function, or False once we know it is unavailable
90
+
91
+
92
+ def _native_unfilter():
93
+ """png_unfilter from png_unfilter.c, compiled once with cc into the cache dir
94
+ (keyed by source hash). None if there is no compiler."""
95
+ global _native
96
+ if _native is not None:
97
+ return _native or None
98
+ _native = False
99
+ try:
100
+ src = _UNFILTER_SRC.read_bytes()
101
+ cache = pathlib.Path(os.environ.get('XDG_CACHE_HOME') or pathlib.Path.home() / '.cache') / 'meltygui'
102
+ so = cache / f'png_unfilter-{hashlib.sha1(src).hexdigest()[:12]}.so'
103
+ if not so.is_file():
104
+ cache.mkdir(parents=True, exist_ok=True)
105
+ tmp = so.with_suffix(f'.{os.getpid()}.tmp')
106
+ subprocess.run(['cc', '-O2', '-shared', '-fPIC', '-o', str(tmp), str(_UNFILTER_SRC)],
107
+ check=True, capture_output=True)
108
+ os.replace(tmp, so)
109
+ fn = ctypes.CDLL(str(so)).png_unfilter
110
+ fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int]
111
+ fn.restype = ctypes.c_int
112
+ _native = fn
113
+ except (OSError, subprocess.CalledProcessError):
114
+ pass
115
+ return _native or None
116
+
117
+
118
+ def _unfilter(raw, bpp):
119
+ """raw: (h, 1 + stride) uint8 with the filter byte first -> (h, stride) unfiltered.
120
+ Sub/Average/Paeth are serial in both axes, so this is a C loop when a compiler
121
+ is around and a (very slow: seconds per Paeth-heavy 1080p frame) Python one
122
+ otherwise."""
123
+ h, stride = raw.shape[0], raw.shape[1] - 1
124
+ fn = _native_unfilter()
125
+ if fn is not None:
126
+ raw = np.ascontiguousarray(raw)
127
+ out = np.empty((h, stride), dtype=np.uint8)
128
+ if fn(raw.ctypes.data, out.ctypes.data, h, stride, bpp) == 0:
129
+ return out
130
+ raise ValueError('bad PNG filter type')
131
+ return _unfilter_py(raw[:, 1:], raw[:, 0], bpp)
132
+
133
+
134
+ def _unfilter_py(rows, filters, bpp):
135
+ out = np.zeros_like(rows)
136
+ prior = np.zeros(rows.shape[1], dtype=np.int32)
137
+ for y in range(rows.shape[0]):
138
+ kind = int(filters[y])
139
+ cur = rows[y].astype(np.int32)
140
+ if kind == 0:
141
+ raw = cur
142
+ elif kind == 2:
143
+ raw = (cur + prior) & 255
144
+ elif kind == 1:
145
+ raw = (np.cumsum(cur.reshape(-1, bpp), axis=0) & 255).reshape(-1)
146
+ else:
147
+ raw = np.zeros_like(cur)
148
+ for i in range(cur.shape[0]):
149
+ a = raw[i - bpp] if i >= bpp else 0
150
+ b = prior[i]
151
+ if kind == 3:
152
+ pred = (a + b) >> 1
153
+ else:
154
+ c = prior[i - bpp] if i >= bpp else 0
155
+ p = a + b - c
156
+ pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
157
+ pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
158
+ raw[i] = (cur[i] + pred) & 255
159
+ out[y] = raw
160
+ prior = raw
161
+ return out
162
+
163
+
164
+ def read_png16(path, data=None):
165
+ """-> (rgb (h, w, 3) uint16, alpha uint16 or None, cicp bytes or None).
166
+ Raises ValueError unless it is a 16-bit RGB/RGBA non-interlaced PNG.
167
+ ``data``: the file's bytes when already in hand (no path needed)."""
168
+ if data is None:
169
+ data = pathlib.Path(path).read_bytes()
170
+ if data[:8] != b'\x89PNG\r\n\x1a\n':
171
+ raise ValueError('not a PNG')
172
+ pos, idat, cicp, header = 8, [], None, None
173
+ while pos + 8 <= len(data):
174
+ length, = struct.unpack('>I', data[pos:pos + 4])
175
+ kind = data[pos + 4:pos + 8]
176
+ body = data[pos + 8:pos + 8 + length]
177
+ if kind == b'IHDR':
178
+ header = struct.unpack('>IIBBBBB', body[:13])
179
+ elif kind == b'cICP':
180
+ cicp = bytes(body[:4])
181
+ elif kind == b'IDAT':
182
+ idat.append(body)
183
+ elif kind == b'IEND':
184
+ break
185
+ pos += 12 + length
186
+ if header is None:
187
+ raise ValueError('PNG without IHDR')
188
+ w, h, depth, ctype, _, _, interlace = header
189
+ if depth != 16 or ctype not in (2, 6) or interlace:
190
+ raise ValueError('not a 16-bit RGB/RGBA PNG')
191
+ channels = 3 if ctype == 2 else 4
192
+ bpp = channels * 2
193
+ raw = np.frombuffer(zlib.decompress(b''.join(idat)), np.uint8).reshape(h, 1 + w * bpp)
194
+ rows = _unfilter(raw, bpp) if raw[:, 0].any() else raw[:, 1:]
195
+ samples = rows.reshape(-1).view('>u2').reshape(h, w, channels).astype(np.uint16)
196
+ return samples[..., :3], (samples[..., 3] if channels == 4 else None), cicp
197
+
198
+
199
+ def png_pq_cicp(path, data=None):
200
+ """(bit depth, primaries) if ``path`` (or ``data``, the file's bytes) is a
201
+ PNG whose cICP chunk says PQ transfer (screenshot-hdr: 16-bit; Blender:
202
+ 8-bit), else None."""
203
+ if data is not None:
204
+ head = bytes(data[:4096])
205
+ else:
206
+ try:
207
+ with open(path, 'rb') as f:
208
+ head = f.read(4096)
209
+ except OSError:
210
+ return None
211
+ if head[:8] != b'\x89PNG\r\n\x1a\n' or len(head) < 25:
212
+ return None
213
+ pos = 8
214
+ while pos + 8 <= len(head):
215
+ length, = struct.unpack('>I', head[pos:pos + 4])
216
+ kind = head[pos + 4:pos + 8]
217
+ if kind == b'cICP':
218
+ primaries, transfer = head[pos + 8], head[pos + 9]
219
+ return (head[24], primaries) if transfer == CICP_PQ else None
220
+ if kind in (b'IDAT', b'IEND'):
221
+ return None
222
+ pos += 12 + length
223
+ return None
224
+
225
+
226
+ def icc_pq_cicp(icc):
227
+ """The primaries code if an ICC profile declares a PQ transfer: its ICC.2
228
+ ``cicp`` tag (Google's "Rec2020 Gamut with PQ Transfer" profile carries
229
+ one), or failing that a description naming PQ. None for an SDR profile."""
230
+ if not icc or len(icc) < 132:
231
+ return None
232
+ try:
233
+ count, = struct.unpack('>I', icc[128:132])
234
+ for i in range(count):
235
+ sig, off, ln = struct.unpack('>4sII', icc[132 + 12 * i:144 + 12 * i])
236
+ if sig == b'cicp' and ln >= 12:
237
+ primaries, transfer = icc[off + 8], icc[off + 9]
238
+ return primaries if transfer == CICP_PQ else None
239
+ if sig == b'desc':
240
+ desc = icc[off:off + ln].replace(b'\x00', b'').lower()
241
+ if b'pq' in desc or b'2100' in desc:
242
+ return 9
243
+ except (struct.error, IndexError):
244
+ pass
245
+ return None
246
+
247
+
248
+ # --- public -------------------------------------------------------------------------------
249
+
250
+ class Loaded:
251
+ """rgb: float32 (h, w, 3) linear scRGB, 1.0 = SDR white — or, for an opaque
252
+ 8-bit sRGB source, srgb8: uint8 (h, w, 3) as decoded, to be linearised by the
253
+ GPU (rgb is then None). hdr: True for PQ sources; peak_nits: brightest
254
+ channel in nits (for the title)."""
255
+ def __init__(self, rgb, hdr, peak_nits, srgb8=None):
256
+ self.rgb, self.hdr, self.peak_nits, self.srgb8 = rgb, hdr, peak_nits, srgb8
257
+
258
+ @property
259
+ def size(self):
260
+ a = self.rgb if self.rgb is not None else self.srgb8
261
+ return a.shape[1], a.shape[0]
262
+
263
+
264
+ def load(path, sdr_white, mark=lambda label: None):
265
+ """Decode ``path`` to linear scRGB. ``mark(label)`` is called after each stage for timing."""
266
+ return _decode(path, None, sdr_white, mark)
267
+
268
+
269
+ def load_bytes(data, sdr_white, mark=lambda label: None):
270
+ """Decode an encoded image held in memory (a PNG / JPEG / WebP payload)
271
+ exactly as `load` decodes a file: PQ PNGs and PQ ICC profiles come out HDR."""
272
+ return _decode(None, bytes(data), sdr_white, mark)
273
+
274
+
275
+ def _decode(path, data, sdr_white, mark):
276
+ pq = png_pq_cicp(path, data)
277
+ if pq and pq[0] == 16:
278
+ mark('sniffed PQ png')
279
+ rgb, alpha, _ = read_png16(path, data)
280
+ mark('png16 decoded')
281
+ linear, peak = pq_to_linear(rgb, pq[1], sdr_white, alpha)
282
+ mark('PQ -> linear scRGB')
283
+ return Loaded(linear, True, peak)
284
+ mark('sniffed: not 16-bit PQ png')
285
+ from PIL import Image, ImageOps
286
+ mark('PIL imported')
287
+ import io
288
+ with Image.open(path if data is None else io.BytesIO(data)) as im:
289
+ im = ImageOps.exif_transpose(im)
290
+ # PQ in an 8-bit container: a PNG with cICP (Blender) or a JPEG/anything
291
+ # with a PQ ICC profile (Chromium honours both; treated as sRGB they
292
+ # show flat and dim, 09-11).
293
+ primaries = pq[1] if pq else icc_pq_cicp(im.info.get('icc_profile'))
294
+ if primaries is not None:
295
+ arr = np.asarray(im.convert('RGBA'))
296
+ linear, peak = pq_to_linear(arr[..., :3], primaries, sdr_white, arr[..., 3])
297
+ mark('PIL decoded, PQ8 -> linear scRGB')
298
+ return Loaded(np.ascontiguousarray(linear, dtype=np.float32), True, peak)
299
+ if im.mode in ('RGBA', 'LA', 'P', 'PA') or os.environ.get('MELTY_NO_SRGB8'):
300
+ im = im.convert('RGBA')
301
+ arr = np.asarray(im, dtype=np.float32) / 255.0
302
+ rgb = srgb_to_linear(arr[..., :3]) * arr[..., 3:4]
303
+ mark('PIL decoded -> linear')
304
+ return Loaded(np.ascontiguousarray(rgb, dtype=np.float32), False, float(rgb.max()) * float(sdr_white))
305
+ srgb8 = np.ascontiguousarray(im.convert('RGB'))
306
+ mark('PIL decoded (sRGB8)')
307
+ peak = srgb_to_linear(np.float32(int(srgb8.max()) / 255.0))
308
+ return Loaded(None, False, float(peak) * float(sdr_white), srgb8=srgb8)
@@ -0,0 +1 @@
1
+ """Feature data interfaces and operations."""
@@ -0,0 +1,142 @@
1
+ """Account model functions and supporting definitions."""
2
+ import json
3
+ import os
4
+ import stat
5
+ import threading
6
+
7
+
8
+ class AccountStore(dict):
9
+ """id -> account dict. `load()` reads the JSON; every mutation goes
10
+ through `set_field` / `add` / `remove` so the file stays in sync and
11
+ the window repaints. Runtime-only keys start with '_' and are never
12
+ written."""
13
+
14
+ def __init__(self):
15
+ super().__init__()
16
+ self._lock = threading.Lock()
17
+ self.loaded = False
18
+ self.error = None
19
+
20
+ def load(self):
21
+ from meltygui.accounts.internet_accounts import ACCOUNTS_PATH
22
+ from meltygui.accounts.internet_accounts import KINDS
23
+
24
+ with self._lock:
25
+ self.clear()
26
+ try:
27
+ if ACCOUNTS_PATH.exists():
28
+ data = json.loads(ACCOUNTS_PATH.read_text())
29
+ for entry in data.get("accounts", []):
30
+ if isinstance(entry, dict) and entry.get("id") and entry.get("kind") in KINDS:
31
+ self[entry["id"]] = entry
32
+ self.error = None
33
+ except Exception as error:
34
+ self.error = f"accounts.json: {error}"
35
+ self.loaded = True
36
+ self.ensure_kinds()
37
+ return self
38
+
39
+ def ensure_kinds(self):
40
+ # default accounts so every kind has a row to act on: the id is the
41
+ # kind name ("anthropic", "copilot", "ollama"); sessions asking for
42
+ # account="default" resolve to it (see `account`).
43
+ from meltygui.accounts.internet_accounts import KINDS
44
+
45
+ for kind in KINDS.values():
46
+ if not any(entry.get("kind") == kind.name for entry in self.values()):
47
+ self.add(kind.name, account_id=kind.name, save=False)
48
+ for entry in self.of_kind(kind.name):
49
+ for field in kind.fields:
50
+ entry.setdefault(field.name, field.default)
51
+
52
+ def save(self):
53
+ from meltygui.accounts.internet_accounts import ACCOUNTS_PATH
54
+
55
+ with self._lock:
56
+ data = {"accounts": [{key: value for key, value in entry.items()
57
+ if not key.startswith("_")}
58
+ for entry in self.values()]}
59
+ try:
60
+ ACCOUNTS_PATH.parent.mkdir(parents=True, exist_ok=True)
61
+ tmp = ACCOUNTS_PATH.with_suffix(".json.tmp")
62
+ with open(tmp, "w") as file:
63
+ file.write(json.dumps(data, indent=2))
64
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
65
+ os.replace(tmp, ACCOUNTS_PATH)
66
+ self.error = None
67
+ except Exception as error:
68
+ self.error = f"accounts.json: {error}"
69
+
70
+ def add(self, kind_name, account_id=None, save=True, **fields):
71
+ from meltygui.accounts.internet_accounts import KINDS
72
+ from meltygui.accounts.internet_accounts import accounts_changed
73
+
74
+ kind = KINDS[kind_name]
75
+ if account_id is None:
76
+ suffix = 2
77
+ while f"{kind_name}-{suffix}" in self:
78
+ suffix += 1
79
+ account_id = f"{kind_name}-{suffix}"
80
+ entry = {"id": account_id, "kind": kind_name,
81
+ "label": fields.pop("label", None) or kind.default_label(account_id)}
82
+ for field in kind.fields:
83
+ entry[field.name] = fields.get(field.name, field.default)
84
+ self[account_id] = entry
85
+ if save:
86
+ self.save()
87
+ accounts_changed()
88
+ return entry
89
+
90
+ def remove(self, account_id):
91
+ """Drop an account. Its live sessions go first — computed while it
92
+ is still in the store, so a removed DEFAULT row also drops the
93
+ sessions pooled under "default" (built on its credentials; the next
94
+ row of the kind becomes the default, see `default_account`)."""
95
+ from meltygui.accounts.internet_accounts import KINDS
96
+ from meltygui.accounts.internet_accounts import _drop_sessions_for
97
+ from meltygui.accounts.internet_accounts import accounts_changed
98
+
99
+ entry = self.get(account_id)
100
+ if entry is None:
101
+ return
102
+ KINDS[entry["kind"]].close(entry)
103
+ _drop_sessions_for(entry)
104
+ self.pop(account_id, None)
105
+ self.save()
106
+ accounts_changed()
107
+
108
+ def set_field(self, account_id, field, value, reprobe=True):
109
+ from meltygui.accounts.internet_accounts import _drop_sessions_for
110
+ from meltygui.accounts.internet_accounts import accounts_changed
111
+
112
+ entry = self.get(account_id)
113
+ if entry is None or entry.get(field) == value:
114
+ return
115
+ entry[field] = value
116
+ if reprobe:
117
+ entry["_status"] = None # stale → re-probe
118
+ entry.pop("_validated", None) # credential changed → re-verify with Test
119
+ _drop_sessions_for(entry) # live sessions hold the old credential
120
+ self.save()
121
+ accounts_changed()
122
+
123
+ def of_kind(self, kind_name):
124
+ return sorted((entry for entry in self.values() if entry.get("kind") == kind_name),
125
+ key=lambda entry: (entry["id"] != kind_name, entry["id"]))
126
+
127
+ def default_account(self, kind_name):
128
+ """The kind's DEFAULT account — what `account(kind, "default")` and
129
+ the providers' `account="default"` resolve to: the entry named
130
+ after its kind, else (that one removed) the first remaining row of
131
+ the kind. Ids never change on promotion, so `account="anthropic-2"`
132
+ references, profile files and panel state all stay put."""
133
+ entry = self.get(kind_name)
134
+ if entry is not None and entry.get("kind") == kind_name:
135
+ return entry
136
+ rows = self.of_kind(kind_name)
137
+ return rows[0] if rows else None
138
+
139
+ def removable(self, account_entry) -> bool:
140
+ """A row can go while its kind keeps at least one other — the last
141
+ one stays (every kind always has a row to act on)."""
142
+ return len(self.of_kind(account_entry.get("kind"))) > 1
@@ -0,0 +1,159 @@
1
+ """
2
+ The draw_voxels orbit camera as math — no GL, no imgui — so the 3D-mouse
3
+ mapping (and the tests) can reason about it offline.
4
+
5
+ The camera is THREE angles about the orbit target `pan`, plus a distance:
6
+
7
+ tilt elevation: orbit over the poles (unrestricted, re-wrapped)
8
+ spin azimuth about the world Z axis
9
+ roll rotation about the view axis (0 = horizon level, the turntable)
10
+ zoom eye distance from the target (cam_zoom)
11
+
12
+ `basis(tilt, spin, roll)` is the ONE definition of the view frame; the GLSL
13
+ raymarcher, the label vertex shader, the CUDA kernel and `_axis_edges`
14
+ mirror it verbatim (roll rotates `right` toward `up` about `fwd`, then `up`
15
+ is re-derived, so roll = 0 is exactly the old two-angle camera).
16
+
17
+ Why angles and not a matrix: the sliders, presets (numpad 7 / 1 / 3), the
18
+ mouse orbit and persistence all speak tilt / spin, and three angles carry
19
+ the full rotation group — nothing is lost. So the 3D mouse never works
20
+ "backwards from a matrix to tilt and spin" (lossy, singular at the poles):
21
+
22
+ - turntable: the puck's axes map straight onto the angles — yaw (ry) is a
23
+ spin increment, pitch (rx) a tilt increment, roll (rz) is ignored. No
24
+ matrix at all; the horizon stays level by construction.
25
+ - trackball: the puck's rotation vector is a small rotation in VIEW space
26
+ applied to the basis, and the new basis is DECOMPOSED back into
27
+ (tilt, spin, roll) — exact, since the decomposition is complete; only
28
+ at the poles (fwd along Z, where spin and roll are the same axis) the
29
+ previous spin is kept and roll absorbs the difference, so nothing jumps.
30
+ """
31
+
32
+ from __future__ import annotations
33
+ import math
34
+
35
+ # Axis order of a space_mouse event's `axes` tuple (utils/space_mouse.py).
36
+ TX, TY, TZ, RX, RY, RZ = range(6)
37
+
38
+
39
+ def basis(tilt, spin, roll=0.0):
40
+ """World-space (fwd, right, up) of the camera — unit vectors."""
41
+ ct, st = math.cos(tilt), math.sin(tilt)
42
+ cs, ss = math.cos(spin), math.sin(spin)
43
+ fwd = (-cs * ct, -ss * ct, -st)
44
+ right0 = (-ss, cs, 0.0)
45
+ up0 = _cross(right0, fwd)
46
+ cr, sr = math.cos(roll), math.sin(roll)
47
+ right = tuple(a * cr + b * sr for a, b in zip(right0, up0))
48
+ up = _cross(right, fwd)
49
+ return fwd, right, up
50
+
51
+
52
+ def decompose(fwd, right, tilt_hint=0.0, spin_hint=0.0):
53
+ """(fwd, right) → (tilt, spin, roll) with basis(...) rebuilding them.
54
+
55
+ `tilt_hint` picks the tilt branch: the stored tilt may sit past a pole
56
+ (cos(tilt) < 0, the mouse orbit's upside-down half) and the SAME
57
+ orientation is then written with the mirrored tilt and roll — keeping
58
+ the branch keeps a trackball nudge from rewriting every angle.
59
+ `spin_hint` is the spin kept at a pole (|cos(tilt)| ~ 0), where spin and
60
+ roll turn about the same axis and only their sum is defined."""
61
+ st = max(-1.0, min(1.0, -fwd[2]))
62
+ tilt = math.asin(st)
63
+ if math.cos(tilt_hint) < 0.0:
64
+ tilt = math.remainder(math.pi - tilt, math.tau)
65
+ ct = math.cos(tilt)
66
+ if abs(ct) > 1e-6:
67
+ sign = 1.0 if ct > 0 else -1.0
68
+ spin = math.atan2(-fwd[1] * sign, -fwd[0] * sign)
69
+ # Keep the previous spin's sign (it runs free past ±pi).
70
+ spin = spin_hint + math.remainder(spin - spin_hint, math.tau)
71
+ else:
72
+ spin = spin_hint
73
+ _, right0, up0 = basis(tilt, spin, 0.0)
74
+ roll = math.atan2(_dot(right, up0), _dot(right, right0))
75
+ return tilt, spin, roll
76
+
77
+
78
+ def rotate_about(v, axis, angle):
79
+ """Rodrigues: rotate vector `v` about unit `axis` by `angle` radians."""
80
+ c, s = math.cos(angle), math.sin(angle)
81
+ k = axis
82
+ kv = _cross(k, v)
83
+ kd = _dot(k, v)
84
+ return tuple(v[i] * c + kv[i] * s + k[i] * kd * (1.0 - c) for i in range(3))
85
+
86
+
87
+ def apply_space_mouse(axes, tilt, spin, roll, zoom, pan, *, navigation,
88
+ orbit_sensitivity, pan_sensitivity, zoom_sensitivity,
89
+ pivot="target"):
90
+ """One frame of 3D-mouse input → the new (tilt, spin, roll, zoom, pan).
91
+
92
+ `pivot` is what a rotation turns about: "target" orbits the camera
93
+ around `pan` (the mouse's orbit — the volume turns on screen), "camera"
94
+ keeps the EYE where it is and turns the view direction — the target
95
+ moves, the scene sweeps across the screen like looking around.
96
+
97
+ `axes` = (tx, ty, tz, rx, ry, rz), each already integrated over the
98
+ frame (full-deflection-seconds), in the reader's view-aligned frame:
99
+ x right, y up, z toward the viewer, in OBJECT terms: the volume moves
100
+ and turns the way the axes say — push right, it goes right; twist, it
101
+ twists. The device's hand (Blender's, space_mouse.BLENDER_SIGNS) is
102
+ applied upstream in space_mouse.normalize, and the mouse trackball feeds
103
+ screen-drag deltas here in the same terms.
104
+ """
105
+ tx, ty, tz, rx, ry, rz = axes
106
+ fwd, right, up = basis(tilt, spin, roll)
107
+ if pivot == "camera":
108
+ # ── free flight: a RIGID translation of eye and target together, a
109
+ # constant world-units-per-second on all three axes (tz forward
110
+ # along the view). The eye-to-target distance never changes, so
111
+ # nothing scales with zoom - a distance-scaled pan and e-fold dolly
112
+ # decayed to a standstill as flying forward drove the distance to 0.
113
+ k = pan_sensitivity
114
+ pan = tuple(p + (r * tx + u * ty + f * tz) * k
115
+ for p, r, u, f in zip(pan, right, up, fwd))
116
+ else:
117
+ # ── orbit: pan in the screen plane, scaled by the camera distance
118
+ # so it covers the same fraction of the view at any zoom (the mouse
119
+ # pan's rule) ──
120
+ k = pan_sensitivity * zoom
121
+ pan = tuple(p + (r * tx + u * ty) * k for p, r, u in zip(pan, right, up))
122
+ # ── dolly: pull toward you = closer. cam_zoom is a distance, so the
123
+ # e-fold rule keeps the feel constant across scales (like the pan).
124
+ zoom = min(137.6, max(0.0, zoom * math.exp(-tz * zoom_sensitivity)))
125
+ # ── rotation ──
126
+ eye = tuple(p - f * zoom for p, f in zip(pan, fwd))
127
+ if navigation == "trackball":
128
+ # The cap's rotation vector in view space. Rotating the VOLUME by
129
+ # +w is rotating the CAMERA by -w about the target.
130
+ wx, wy, wz = rx * orbit_sensitivity, ry * orbit_sensitivity, rz * orbit_sensitivity
131
+ angle = math.sqrt(wx * wx + wy * wy + wz * wz)
132
+ if angle > 0.0:
133
+ axis = tuple((right[i] * wx + up[i] * wy + fwd[i] * wz) / angle for i in range(3))
134
+ fwd = rotate_about(fwd, axis, -angle)
135
+ right = rotate_about(right, axis, -angle)
136
+ tilt, spin, roll = decompose(fwd, right, tilt, spin)
137
+ else:
138
+ # Turntable: yaw about world up (a rightward twist spins the volume
139
+ # leftward as seen - upside-down the world axis points into the
140
+ # screen, so the sign follows cos(tilt), the mouse orbit's chirality
141
+ # rule, per frame since the keys has no screen edges to latch on).
142
+ spin_sign = -1.0 if math.cos(tilt) < 0.0 else 1.0
143
+ spin -= ry * orbit_sensitivity * spin_sign
144
+ tilt = math.remainder(tilt + rx * orbit_sensitivity, math.tau)
145
+ if pivot == "camera":
146
+ # The eye stays put: re-seat the target along the NEW view direction.
147
+ fwd, _, _ = basis(tilt, spin, roll)
148
+ pan = tuple(e + f * zoom for e, f in zip(eye, fwd))
149
+ return tilt, spin, roll, zoom, pan
150
+
151
+
152
+ def _cross(a, b):
153
+ return (a[1] * b[2] - a[2] * b[1],
154
+ a[2] * b[0] - a[0] * b[2],
155
+ a[0] * b[1] - a[1] * b[0])
156
+
157
+
158
+ def _dot(a, b):
159
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]