-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy path3dm.py
More file actions
executable file
·304 lines (246 loc) · 10.4 KB
/
3dm.py
File metadata and controls
executable file
·304 lines (246 loc) · 10.4 KB
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
#! /usr/bin/env python3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Tuple, List, Literal, Union, Dict, Any, Callable
from concurrent.futures import ThreadPoolExecutor
import argparse
import os
import sys
import tempfile
import tomllib
import shutil
from packaging.version import Version
from platformdirs import user_config_path
import actions.help_action
from version import VERSION
from coretypes import FileSet, CommandOptions
from utils.prompts import yes_or_no, prompt
from utils.update_check import newer_3dmake_version, DOWNLOAD_URL
from actions import ALL_ACTIONS_IN_ORDER, Context
CONFIG_DIR = Path(os.environ['3DMAKE_CONFIG_DIR']) if '3DMAKE_CONFIG_DIR' in os.environ else user_config_path('3dmake', None)
PROFILES_DIR = CONFIG_DIR / 'profiles'
OVERLAYS_DIR = CONFIG_DIR / 'overlays'
def error_out(message: str):
print(message)
sys.exit(1)
def load_config() -> Tuple[CommandOptions, Optional[Path]]:
""" Returns merged options, project root """
project_root = None
# First load the global defaults
with open(CONFIG_DIR / "defaults.toml", 'rb') as fh:
settings_dict = tomllib.load(fh)
# TODO should we check parent dirs?
if Path('./3dmake.toml').exists():
with open("./3dmake.toml", 'rb') as fh:
settings_dict.update(tomllib.load(fh))
project_root = Path().absolute()
elif Path('../3dmake.toml').exists():
print("Using 3DMake project in parent directory...")
with open("../3dmake.toml", 'rb') as fh:
settings_dict.update(tomllib.load(fh))
project_root = Path('..').resolve()
if project_root and 'project_name' not in settings_dict:
settings_dict['project_name'] = project_root.parts[-1]
# Force library names to be lowercase to avoid confusing case issues
if 'libraries' in settings_dict:
settings_dict['libraries'] = [k.lower() for k in settings_dict['libraries']]
return CommandOptions(**settings_dict), project_root
class HelpAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
actions.help_action.help(None)
parser.exit()
parser = argparse.ArgumentParser(
prog='3dmake',
add_help=False,
)
parser.add_argument('-s', '--scale') # can be either "auto" or a float
parser.add_argument('-m', '--model')
parser.add_argument('-v', '--view', type=str)
parser.add_argument('-p', '--profile', type=str)
parser.add_argument('-o', '--overlay', action='extend', nargs=1)
parser.add_argument('-a', '--angle', action="extend", nargs=1)
parser.add_argument('-i', '--interactive', action='store_true')
parser.add_argument('-c', '--copies', type=int, default=1)
parser.add_argument('--colorscheme', type=str)
parser.add_argument('--image-size', type=str)
parser.add_argument('--help', '-h', action=HelpAction, nargs=0)
parser.add_argument('--debug', action='store_true')
parser.add_argument('actions_and_files', nargs='+')
# We follow this strategy of using parse_known_args() rather than parse_args()
# so that we can interleave actions_and_files with dashed options
args, unknown_args = parser.parse_known_args()
unknown_dashed_args = [e for e in unknown_args if e.startswith('-')]
if unknown_dashed_args:
error_out(f"Unknown option(s): {' '.join(unknown_dashed_args)}")
args
extras = args.actions_and_files + unknown_args
infiles = []
while extras and '.' in extras[-1]:
infiles.append(extras.pop())
verbs = set([x.lower() for x in extras])
if not len(verbs):
error_out("Must provide an action verb")
# Check that 3dmake has been set up on this machine; if not do so
if not CONFIG_DIR.exists() and verbs != {'setup'}:
print("3DMake settings and print options have not been set up on this machine.")
if not yes_or_no("Do you want to set them up now?"):
sys.exit(0)
verbs = {'setup'}
infiles = []
# This is kind of hacky but it allows us to `3dm info foo.scad`
# TODO this has an edge case where you can 3dm list-proifles foo.scad which
# would be good to fix at some point.
if infiles and Path(infiles[0]).suffix.lower() == '.scad':
verbs.add('build')
# Check verbs and insert any implied ones (recursively)
def add_implied_actions(verb_name, mutable_verbs_set):
action = ALL_ACTIONS_IN_ORDER.get(verb_name)
if action:
for dependency in action.implied_actions:
if dependency not in mutable_verbs_set:
mutable_verbs_set.add(dependency)
add_implied_actions(dependency, mutable_verbs_set)
verb_count = len(verbs)
should_load_options = False
needs_project_files = False
accepted_input_types: set[str] = set()
for verb in list(verbs):
action = ALL_ACTIONS_IN_ORDER.get(verb)
if not action or action.internal:
raise error_out(f"Unknown action '{verb}'")
if action.isolated and verb_count > 1:
raise error_out(f"The action '{verb}' can only be used on its own")
if action.needs_options:
should_load_options = True
if action.uses_project_files:
needs_project_files = True
if action.input_file_type:
accepted_input_types.add(action.input_file_type)
# Add implied actions recursively
add_implied_actions(verb, verbs)
# Validate last_in_chain constraints
action_names_in_order = list(ALL_ACTIONS_IN_ORDER.keys())
requested_actions_in_order = [name for name in action_names_in_order if name in verbs]
for i, action_name in enumerate(requested_actions_in_order):
action = ALL_ACTIONS_IN_ORDER[action_name]
if action.last_in_chain:
# Check if there are any later actions in the internal ordering
later_actions = requested_actions_in_order[i+1:]
if later_actions:
error_out(f"Action '{action_name}' cannot be followed by other actions. Found: {', '.join(later_actions)}")
# Load options if necessary
options, project_root, file_set = None, None, None
if should_load_options:
options, project_root = load_config()
# Check minimum version requirement if specified in project config
if options.min_3dmake_version and project_root:
current_version = Version(VERSION)
min_required_version = Version(str(options.min_3dmake_version)) # Str in case they put a float in
if current_version < min_required_version:
error_out(f"This project requires 3DMake version {options.min_3dmake_version} or newer. "
f"Current version is {VERSION}. Please update 3DMake to continue.")
if args.scale: # TODO support x,y,z scaling
if args.scale.replace('.', '').isdecimal():
options.scale = float(args.scale)
elif args.scale.lower() == 'auto':
options.scale = 'auto'
else:
raise error_out("Invalid value for --scale, must be a decimal number or auto")
if args.model:
if infiles:
raise error_out("Cannot select a model name when using an input file")
mod_name = args.model
# Help the user out if they accidentally put in a filename
if mod_name.lower().endswith('.scad'):
mod_name = mod_name[:-5]
options.model_name = mod_name
if args.view:
options.view = args.view
if args.profile:
options.printer_profile = args.profile
if args.overlay:
options.overlays = args.overlay
if args.angle:
options.image_angles = args.angle
if args.colorscheme:
options.colorscheme = args.colorscheme
if args.image_size:
options.image_size = args.image_size
if args.copies:
options.copies = args.copies
if args.interactive:
options.interactive = True
if args.debug:
options.debug = True
if options.scale == 'auto':
error_out("Auto-scaling is not supported yet") # TODO
file_set = FileSet(options, project_root)
if len(infiles) > 1:
error_out("Multiple inputs not supported yet")
elif infiles:
single_infile = Path(infiles[0])
extension = single_infile.suffix.lower()
if extension not in accepted_input_types:
if not accepted_input_types:
error_out("This action does not take an input file")
else:
error_out(f"This action does not accept an input file of type {extension}")
options.model_name = single_infile.stem # Derive the model name from the STL/scad name
file_set.build_dir = Path(tempfile.mkdtemp())
file_set.explicit_input_file = single_infile
if extension == '.stl':
file_set.scad_source = None
file_set.model = single_infile
elif extension == '.scad':
file_set.scad_source = single_infile
file_set.model = file_set.build_dir / f"{options.model_name}.stl"
elif not needs_project_files:
pass # Skip setting up the project root since we shouldn't need it
elif project_root:
file_set.scad_source = project_root / "src" / f"{options.model_name}.scad"
else:
raise error_out("Must either specify input file or run in a 3DMake project directory")
context = Context(
config_dir=CONFIG_DIR,
options=options,
files=file_set,
explicit_overlay_arg=args.overlay,
single_file_mode=bool(infiles),
)
with ThreadPoolExecutor(max_workers=1) as executor:
try:
update_check_future = executor.submit(newer_3dmake_version, CONFIG_DIR, VERSION)
for name, action in ALL_ACTIONS_IN_ORDER.items():
if name in verbs:
try:
action(context)
except Exception as e:
if options and options.debug:
raise
else:
error_out("ERORR: " + str(e))
except KeyboardInterrupt as e:
print("Exited.")
sys.exit(2)
if action.isolated:
sys.exit(0)
if infiles:
outputs = file_set.final_outputs()
if outputs:
for file in outputs:
shutil.copy(file, Path('.'))
if len(outputs) == 1:
print(f"Result is in {outputs[0].name}")
else:
print(f"Result files:")
for file in outputs:
print(f" {file.name}")
print("Done.")
finally:
try:
new_version = update_check_future.result(timeout=3)
if new_version:
print(f"A newer version of 3DMake is available at {DOWNLOAD_URL}")
print(f"You are running version {VERSION}. The latest is {new_version}.")
except TimeoutError:
pass