-
Notifications
You must be signed in to change notification settings - Fork 16
/
Copy pathsublime3dsmax.py
367 lines (286 loc) · 12.1 KB
/
sublime3dsmax.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
"""Send maxscript/python files or codelines to 3ds Max.
This is the main sublime plugin file. It currently implements 4 commands
that you can bind to keys:
- SendFileToMaxCommand aka send_file_to_max
- SendSelectionToMaxCommand aka send_selection_to_max
- SelectMaxInstanceCommand aka select_max_instance
- OpenMaxHelpCommand aka open_max_help
See the README for details on how to use them.
"""
from __future__ import unicode_literals
import os
import re
import webbrowser
import zipfile
import sublime
import sublime_plugin
from . import constants
from . import filters
from . import winapi
__version__ = "0.11.0"
DEFAULT_DOCS_VERSION = "2019"
# Holds the current 3ds Max window object that we send commands to.
# It is filled automatically when sending the first command.
mainwindow = None
# Used to preselect the last 3ds Max window in the quick panel.
last_index = 0
def _get_api_lines():
"""Read the mxs API definition file and return as a list of lines."""
def get_decoded_lines(file_obj):
content = file_obj.read()
try:
content = content.decode("utf-8")
except (UnicodeDecodeError, AttributeError):
pass
return content.split("\n")
# Zipped .sublime-package as installed by package control.
if ".sublime-package" in constants.APIPATH:
apifile = os.path.basename(constants.APIPATH)
package = zipfile.ZipFile(os.path.dirname(constants.APIPATH), "r")
return get_decoded_lines(package.open(apifile))
# Expanded folder, e.g. during development.
else:
return get_decoded_lines(open(constants.APIPATH))
def _is_maxscriptfile(filepath):
"""Return if the file uses one of the MAXScript file extensions."""
name, ext = os.path.splitext(filepath)
return ext in (".ms", ".mcr", ".mse", ".mzp")
def _is_pythonfile(filepath):
"""Return if the file uses a Python file extension."""
name, ext = os.path.splitext(filepath)
return ext in (".py",)
def _save_to_tempfile(text):
"""Store code in a temporary maxscript file."""
with open(constants.TEMPFILE, "w") as tempfile:
tempfile.write(text)
def _send_cmd_to_max(cmd):
"""Try to find the 3ds Max window by title and the mini
macrorecorder by class.
Sends a string command and a return-key buttonstroke to it to
evaluate the command.
"""
global mainwindow
if mainwindow is None:
mainwindow = winapi.Window.find_window(
constants.TITLE_IDENTIFIER)
if mainwindow is None:
sublime.error_message(constants.MAX_NOT_FOUND)
return
try:
mainwindow.find_child(text=None, cls="MXS_Scintilla")
except OSError:
# Window handle is invalid, 3ds Max has probably been closed.
# Call this function again and try to find one automatically.
mainwindow = None
_send_cmd_to_max(cmd)
return
minimacrorecorder = mainwindow.find_child(text=None, cls="MXS_Scintilla")
# If the mini macrorecorder was not found, there is still a chance
# we are targetting an ancient Max version (e.g. 9) where the
# listener was not Scintilla based, but instead a rich edit box.
if minimacrorecorder is None:
statuspanel = mainwindow.find_child(text=None, cls="StatusPanel")
if statuspanel is None:
sublime.error_message(constants.RECORDER_NOT_FOUND)
return
minimacrorecorder = statuspanel.find_child(text=None, cls="RICHEDIT")
# Verbatim strings (the @ at sign) are also not yet supported.
cmd = cmd.replace("@", "")
cmd = cmd.replace("\\", "\\\\")
if minimacrorecorder is None:
sublime.error_message(constants.RECORDER_NOT_FOUND)
return
sublime.status_message('Send to 3ds Max: {cmd}'.format(
**locals())[:-1]) # Cut ';'
cmd = cmd.encode("utf-8") # Needed for ST3!
minimacrorecorder.send(winapi.WM_SETTEXT, 0, cmd)
minimacrorecorder.send(winapi.WM_CHAR, winapi.VK_RETURN, 0)
minimacrorecorder = None
def _get_max_version():
"""Try to determine the version of 3ds Max we are connected to."""
global mainwindow
if mainwindow is None:
mainwindow = winapi.Window.find_window(
constants.TITLE_IDENTIFIER)
# Default to 2018 help, this has the most updated docs and will
# filter to Maxscript results.
max_version = DEFAULT_DOCS_VERSION
if mainwindow is not None:
window_text = mainwindow.get_text()
matches = re.findall(r"(?:Max )(2\d{3})", window_text)
if matches:
last_match = matches[-1]
max_version = last_match
return max_version
class SendFileToMaxCommand(sublime_plugin.TextCommand):
"""Send the current file by using 'fileIn <file>'."""
def run(self, edit):
currentfile = self.view.file_name()
if currentfile is None:
sublime.error_message(constants.NOT_SAVED)
return
is_mxs = _is_maxscriptfile(currentfile)
is_python = _is_pythonfile(currentfile)
if is_mxs:
cmd = 'fileIn @"{0}"\r\n'.format(currentfile)
_send_cmd_to_max(cmd)
elif is_python:
cmd = 'python.executeFile @"{0}"\r\n'.format(currentfile)
_send_cmd_to_max(cmd)
else:
sublime.error_message(constants.NO_SUPPORTED_FILE)
class SendSelectionToMaxCommand(sublime_plugin.TextCommand):
"""Send selected part of the file.
Selection is extended to full line(s).
"""
def expand(self, line):
"""Expand selection to encompass whole line."""
self.view.run_command("expand_selection", {"to": line.begin()})
def run(self, edit):
"""Analyse selection and determine a method to send it to 3ds Max.
Single line maxscript commands can be send directly. Python
commands could, but since we wrap them we may get issues with
quotation marks or backslashes, so it is safer to send them via
a temporary file that we import. That is also the method to send
multiline code, since the mini macrorecorder does not accept
multiline input.
"""
def get_mxs_tempfile_import():
return 'fileIn @"{0}"\r\n'.format(constants.TEMPFILE)
def get_python_tempfile_import():
return 'python.executeFile @"{0}"\r\n'.format(constants.TEMPFILE)
# We need the user to have an actual file opened so that we can
# derive the language from its file extension.
currentfile = self.view.file_name()
if not currentfile:
sublime.error_message(constants.NOT_SAVED)
return
is_mxs = _is_maxscriptfile(currentfile)
is_python = _is_pythonfile(currentfile)
regions = [region for region in self.view.sel()]
for region in regions:
line = self.view.line(region)
text = self.view.substr(line)
is_empty = region.empty()
is_singleline = len(text.split("\n")) == 1
is_multiline = not (is_empty or is_singleline)
if is_multiline:
self.expand(line)
_save_to_tempfile(text)
if not os.path.isfile(constants.TEMPFILE):
sublime.error_message(constants.NO_TEMP)
return
if is_mxs:
cmd = get_mxs_tempfile_import()
else:
cmd = get_python_tempfile_import()
_send_cmd_to_max(cmd)
return
else:
if is_empty:
self.expand(line)
text = self.view.substr(self.view.line(region))
elif is_singleline:
text = self.view.substr(region)
if is_mxs:
cmd = '{0}\r\n'.format(text)
elif is_python:
_save_to_tempfile(text)
if not os.path.isfile(constants.TEMPFILE):
sublime.error_message(constants.NO_TEMP)
return
cmd = get_python_tempfile_import()
_send_cmd_to_max(cmd)
return
class OpenMaxHelpCommand(sublime_plugin.TextCommand):
"""Open the online MAXScript help searching for the current selection."""
# Based on: https://forum.sublimetext.com/t/select-word-under-cursor-for-further-processing/10913 # noqa
def run(self, edit):
for region in self.view.sel():
if region.begin() == region.end():
word = self.view.word(region)
else:
word = region
if not word.empty():
keyword = self.view.substr(word)
url = self.get_query_help_url(keyword)
webbrowser.open(url, new=0, autoraise=True)
def get_query_help_url(self, keyword):
"""Return a URL to the MAXScript help, looking for given keyword.
The docs may need special handling regarding filtering and query
parameters.
Test URL for Max 2019:
http://help.autodesk.com/view/3DSMAX/2019/ENU/index.html?query=polyOp&cg=Scripting%20%26%20Customization # noqa
"""
query_param = "?query=" + keyword
max_version = _get_max_version()
url = constants.ONLINE_MAXSCRIPT_HELP_URL[max_version] + query_param
if max_version == DEFAULT_DOCS_VERSION:
# Make sure to search in a specific section of the docs.
url += r"&cg=Scripting%20%26%20Customization"
return url
class SelectMaxInstanceCommand(sublime_plugin.TextCommand):
"""Display a dialog of open 3ds Max instances to pick one.
The chosen instance is used from then on to send commands to.
"""
def run(self, edit):
item2window = {}
candidates = winapi.Window.find_windows(
constants.TITLE_IDENTIFIER)
for window in candidates:
text = window.get_text()
normtext = text.replace("b'", "").replace("'", "")
item = ("{txt} ({hwnd})".format(txt=normtext,
hwnd=window.get_handle()))
item2window[item] = window
items = list(item2window.keys())
def on_select(idx):
if idx == -1:
return
global last_index
last_index = idx
item = items[idx]
global mainwindow
mainwindow = item2window[item]
sublime.message_dialog(constants.PREFIX +
" Now connected to: \n\n" + item)
def on_highlighted(idx):
pass
sublime.active_window().show_quick_panel(items,
on_select,
0,
last_index,
on_highlighted)
class Completions(sublime_plugin.EventListener):
"""Handle auto-completion from file content and the official API.
To test this feature try typing the following in a .ms file:
polyOps.
It should offer autocompletions like:
polyOps.retriangulate
polyOps.autosmooth
...
"""
completions_list = []
def is_mxs(self, view):
return view.match_selector(view.id(), "source.maxscript")
def on_activated(self, view):
if self.is_mxs(view) and not self.completions_list:
self.completions_list = _get_api_lines()
def on_query_completions(self, view, prefix, locations):
if self.is_mxs(view):
self.completions_list = _get_api_lines()
comp_default = set(view.extract_completions(prefix))
completions = set(list(self.completions_list))
comp_default = comp_default - completions
completions = list(comp_default) + list(completions)
completions = [(attr, attr) for attr in completions]
completions = filters.manager.apply_filters(
view, prefix, locations, completions)
return completions
def plugin_unloaded():
"""Perform cleanup work."""
if os.path.isfile(constants.TEMPFILE):
try:
os.remove(constants.TEMPFILE)
except OSError:
pass